Compare commits
122 Commits
87549731c8
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f1e26552d2 | |||
| f40aa538b0 | |||
| 9883e6aab3 | |||
| 3d18f73965 | |||
| 985d51c4ba | |||
| 52125a5a98 | |||
| d95b93992d | |||
| d20517804d | |||
| 91bd6afd1f | |||
| dd2356cf04 | |||
| 3475c1811b | |||
| 91be282ec6 | |||
| 310e7f699d | |||
| e7d5b1c60b | |||
| 69fac2f26e | |||
| c222dd7d41 | |||
| 6565b7d679 | |||
| b148b558c3 | |||
| 1ad01534f1 | |||
| 253b80c500 | |||
| f569293d52 | |||
| d47131cd9f | |||
| 1639261cde | |||
| fa1bd41c92 | |||
| 3cabf12f68 | |||
| 9bfc5e23f7 | |||
| 9e43de6fc1 | |||
| 80320ae698 | |||
| 939fe122c5 | |||
| c72e510fb0 | |||
| 6c43b8637e | |||
| 3765d4d476 | |||
| 10ae670962 | |||
| daeb7e965d | |||
| 606bf53019 | |||
| fd1be5e7e4 | |||
| 5a7909bb55 | |||
| 86fd400f0b | |||
| dd2ae51b43 | |||
| 719d5c5719 | |||
| bda02f67eb | |||
| 9053ef0588 | |||
| 0601aafd2d | |||
| 8b3e43f30d | |||
| 6330f31162 | |||
| b47cbf03c5 | |||
| 407ef37004 | |||
| 64b942447b | |||
| 8b52c5fe3c | |||
| fbd213f323 | |||
| 1b96a3b2c7 | |||
| 24ca87a47a | |||
| 4eb41608ea | |||
| 6e3f0ede4d | |||
| fe818e9946 | |||
| 5e26834e16 | |||
| 54c1d96933 | |||
| a51a585b14 | |||
| fc66b57e78 | |||
| ad2425b0ff | |||
| d848d94604 | |||
| ca0255e13b | |||
| d96d9d6333 | |||
| 7397ec30cd | |||
| 75eec35365 | |||
| b3d28cd5d8 | |||
| 5bd1333928 | |||
| bf7c83c408 | |||
| 87ac1e09b0 | |||
| 8adaf3bfd3 | |||
| 8a451a0c49 | |||
| 5635be1322 | |||
| d7bbcc5337 | |||
| f3a8952947 | |||
| 4d9a9e7e2b | |||
| 9484160045 | |||
| 6acbfe22de | |||
| ca90f5c296 | |||
| 191fb4e2cf | |||
| 8c1a8fddb1 | |||
| 05408e17c3 | |||
| d3517a5706 | |||
| 2c2f78eba9 | |||
| ec5ec62240 | |||
| 115289ef35 | |||
| fc9d630263 | |||
| 01d72d0f68 | |||
| a35e50ccd1 | |||
| 34a1670ea9 | |||
| c21043d845 | |||
| 07b97bd111 | |||
| f411d8f070 | |||
| d820d941c1 | |||
| cd2f27792d | |||
| 75f4c475df | |||
| 2eb06acb5b | |||
| 6fea8262fb | |||
| 2e839e0b16 | |||
| cc8f50de89 | |||
| 279ea925a8 | |||
| c8e3eef9d7 | |||
| 2fe492b06b | |||
| 154ba3efc2 | |||
| df4c93d243 | |||
| db95be39f3 | |||
| 2aec488226 | |||
| b328d32f97 | |||
| 308e075f35 | |||
| 63cc2954af | |||
| 45f45145f0 | |||
| 55f047cd35 | |||
| 8a97bbfb43 | |||
| 2069e57f14 | |||
| 6b288bfa49 | |||
| 6bff073108 | |||
| 06c6748274 | |||
| eac1e0b59e | |||
| 36ccfc9471 | |||
| 5b2aeb0183 | |||
| 3d36faa49d | |||
| e022f2f213 | |||
| f0bd31c544 |
@@ -1,32 +0,0 @@
|
||||
---
|
||||
description: Global token-saving, cost control, and context management rules
|
||||
globs: *
|
||||
---
|
||||
|
||||
# Global Optimization & Cost Control
|
||||
|
||||
## ?? Answers
|
||||
-Always answer briefly and only to the point, otherwise only if it is stated in the question or when it is not possible to answer briefly.
|
||||
|
||||
## ?? Context & Local Data
|
||||
- **Strict file targeting:** Work ONLY with files explicitly provided via `@` or open in the active editor tab. Do not use global codebase search unless requested.
|
||||
- **Local assets only:** Always work with local copies of repositories and dependencies. Never request external web resources or re-download packages without a explicit build error.
|
||||
- **Size Limit:** Do not load files larger than 500 lines into the context unless strictly necessary.
|
||||
|
||||
## ?? Git & Commits Management
|
||||
- **Automatic commits are strictly FORBIDDEN.** Never execute `git commit` or `git push` without an explicit user command.
|
||||
- Only suggest a commit after phrases like: "Ñäåëàé êîììèò", "Çàôèêñèðóé èçìåíåíèÿ", "Push".
|
||||
- Before committing: display `git diff --stat` and wait for explicit user confirmation.
|
||||
- Use Conventional Commits format (`feat:`, `fix:`, `refactor:`, `docs:`).
|
||||
|
||||
## ?? Output Format & Brevity
|
||||
- **Strictly no fluff:** Omit greetings, apologies, and closing pleasantries.
|
||||
- **Diff-style only:** Output only modified code fragments, never duplicate unchanged logic or whole files.
|
||||
- **Extreme brevity:** Explanations must be 1-3 sentences max. Use documentation links instead of long texts.
|
||||
- If a task doesn't require code, respond strictly with text. If in doubt, ask ONE precise clarifying question.
|
||||
|
||||
## ?? Model Routing Reminder
|
||||
- Simple questions, explanations, docs ? Use lightweight models (e.g., `gpt-4o-mini`, `claude-3-haiku`).
|
||||
- Complex code generation, refactoring, and debugging ? Use advanced models (e.g., `claude-3.5-sonnet`).
|
||||
- Do not switch models without an explicit reason.
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
---
|
||||
description: При правках агентских скриптов (RDP/SSH) всегда bump версии — иначе ПК не обновятся
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Версии агентов: обязательный bump
|
||||
|
||||
Workspace **security-monitors** (RDP + SSH + SAC). Любая **существенная правка агентских скриптов** без bump версии **не доедет до компьютеров**.
|
||||
|
||||
## RDP-login-monitor
|
||||
|
||||
| Файл | Поле |
|
||||
|------|------|
|
||||
| `Login_Monitor.ps1` | `$ScriptVersion` |
|
||||
| `version.txt` | та же строка |
|
||||
|
||||
Триггер деплоя на Windows: **`Deploy-LoginMonitor.ps1`** сравнивает `version.txt` на шаре с `C:\ProgramData\RDP-login-monitor\deployed_version.txt`.
|
||||
|
||||
## ssh-monitor
|
||||
|
||||
| Файл | Поле |
|
||||
|------|------|
|
||||
| `ssh-monitor` | `SSH_MONITOR_VERSION` |
|
||||
| `version.txt` | та же строка |
|
||||
|
||||
Обновление на Linux: **`update_ssh_monitor.sh`** (sha256 + перезапуск сервиса).
|
||||
|
||||
## Правило для агента
|
||||
|
||||
При изменении `Login_Monitor.ps1`, `Deploy-LoginMonitor.ps1`, `Sac-Client.ps1`, `ssh-monitor`, `sac-client.sh`, `update_ssh_monitor.sh`:
|
||||
|
||||
1. Поднять **patch** версии (`2.0.6-SAC` → `2.0.7-SAC`) в **обоих** местах репозитория
|
||||
2. Включить bump в **тот же коммит**, что и правку (или сразу следом `chore: bump release to …`)
|
||||
3. Напомнить: опубликовать RDP на NETLOGON / на SSH-хостах выполнить update
|
||||
|
||||
## SAC (backend)
|
||||
|
||||
Версия UI/API — **`backend/app/version.py`** и **`frontend/src/version.ts`** (`APP_VERSION`, должны совпадать). Bump при релизе SAC на сервер; на автообновление **агентов на ПК** не влияет.
|
||||
@@ -1,30 +0,0 @@
|
||||
---
|
||||
description: Запрет упоминаний Cursor IDE/Agent в git-репозиториях (файлы и коммиты)
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Не оставлять следов Cursor в репозиториях
|
||||
|
||||
В **коммитах и файлах**, которые попадают в git (включая публичный GitHub), **запрещено** добавлять что-либо, связанное с Cursor как инструментом разработки.
|
||||
|
||||
## Запрещено
|
||||
|
||||
- Упоминания **Cursor**, **Cursor IDE**, **Cursor Agent**, **cursoragent**, **cursor.com**, **cursor.sh** в README, docs, комментариях, конфигах.
|
||||
- Trailers в сообщениях коммитов: `Co-authored-by: Cursor …`, `Made-with: Cursor`, email `cursoragent@cursor.com`.
|
||||
- Каталог **`.cursor/`**, файлы только для IDE (**`AGENTS.md`**, **`CURSOR.md`**) — **не коммитить** в публичные репозитории (должны быть в `.gitignore`, если репо public).
|
||||
- Ссылки на Cursor в runbook, deploy-доках, issue templates.
|
||||
|
||||
## Разрешено (не путать с IDE)
|
||||
|
||||
- CSS: **`cursor: pointer`** (и аналоги).
|
||||
- Предметный код: **poll cursor**, **CurrentCursor**, **Update-MonitorPollCursor**, **Console.CursorLeft**, **DB cursor** (sqlite/psycopg2), **MOEX `analytics.cursor`** и т.п.
|
||||
|
||||
## Git / GitHub
|
||||
|
||||
- **Author и committer** — только владелец проекта (**Andrey Lutsenko**, email, привязанный к GitHub), **без** co-author Cursor.
|
||||
- Перед push в public remote: нет `cursoragent`, `Co-authored-by: Cursor` в истории и дереве (`git log --format=fuller`, поиск по репо).
|
||||
|
||||
## При работе агента
|
||||
|
||||
- Не добавлять «Powered by Cursor», бейджи, секции про Cursor в документацию продукта.
|
||||
- Правила Cursor (`.cursor/rules/`) — локально или только в **приватном** remote; в публичный GitHub не попадают.
|
||||
@@ -1,18 +0,0 @@
|
||||
---
|
||||
description: High-density PowerShell script generation rules for minimal token usage
|
||||
globs: "*.ps1, *.psm1"
|
||||
---
|
||||
|
||||
# PowerShell Token Optimization
|
||||
|
||||
- **Use Short Aliases:** Use short aliases instead of full cmdlet names to drastically cut output tokens:
|
||||
- Use `gc` instead of `Get-Content`
|
||||
- Use `gci` instead of `Get-ChildItem`
|
||||
- Use `%` instead of `ForEach-Object`
|
||||
- Use `?` instead of `Where-Object`
|
||||
- Use `measure` instead of `Measure-Object`
|
||||
- **Pipeline Over Loops:** Prefer pipeline chains (`gci | % { ... }`) over multi-line `foreach ($item in $items) { ... }` blocks.
|
||||
- **No Help/Comments:** Do not generate `.SYNOPSIS`, `.DESCRIPTION`, or comment-based help at the top of scripts.
|
||||
- **Omit Parameter Names:** Drop explicit parameter names where positional arguments are clear (e.g., use `gc file.txt` instead of `Get-Content -Path file.txt`).
|
||||
- **Silent Execution:** Do not add verbose logging, `Write-Host`, or `Write-Output` unless explicitly asked to create UI/logs.
|
||||
- **Preserve CLI Arguments:** Do not duplicate full multi-line `yt-dlp` command-line arguments, format strings, or output templates if they are not the subject of the modifications. Use placeholders or variable references.
|
||||
@@ -1,14 +0,0 @@
|
||||
---
|
||||
description: Ultra-dense Python code generation rules to save output tokens
|
||||
globs: "*.py"
|
||||
---
|
||||
|
||||
# Python Token Optimization
|
||||
|
||||
- **Use Syntactic Sugar:** Prioritize list comprehensions, dict comprehensions, and ternary operators (`x if condition else y`) to keep code on a single line.
|
||||
- **Built-in Libraries First:** Use standard libraries (`pathlib`, `json`, `subprocess`, `asyncio`) instead of introducing heavy external dependencies unless already in `requirements.txt`.
|
||||
- **Type Hinting:** Do NOT add type hints (`def func(x: int) -> str:`) unless the existing file strictly uses them. Type hints consume significant tokens.
|
||||
- **No Format Duplication:** When modifying scripts (like video downloaders), provide only the modified function or class method. Never output the `if __name__ == "__main__":` block or argument parsing logic if they haven't changed.
|
||||
- **No Docstrings:** Strictly forbid writing `"""docstrings"""` or `# comments` explaining the logic.
|
||||
- **Preserve yt-dlp Options:** Never rewrite, duplicate, or expand the `ydl_opts` configuration dictionary or custom extraction options. If changes are unrelated to download options, use a placeholder comment like `# ... existing ydl_opts ...` instead of outputting the full dictionary block.
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
---
|
||||
description: При правке скриптов RDP-монитора обязательно поднимать version.txt и $ScriptVersion
|
||||
globs: Login_Monitor.ps1,Deploy-LoginMonitor.ps1,Sac-Client.ps1,Exchange-MailSecurity.ps1,Watchdog_RDP_Monitor.ps1,version.txt
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Bump версии RDP-login-monitor
|
||||
|
||||
Без новой версии **`Deploy-LoginMonitor.ps1` на ПК не подхватит обновление** (сравнение `version.txt` на шаре с `deployed_version.txt`).
|
||||
|
||||
## Что менять в одном коммите с правкой
|
||||
|
||||
1. **`Login_Monitor.ps1`** — `$ScriptVersion` (например `2.0.7-SAC`)
|
||||
2. **`version.txt`** — **та же строка**, одна строка без лишнего текста
|
||||
|
||||
Строки должны **совпадать** (включая суффикс `-SAC`).
|
||||
|
||||
## Как поднимать
|
||||
|
||||
- Обычная правка монитора / deploy / SAC-клиента → **patch**: `2.0.6-SAC` → `2.0.7-SAC`
|
||||
- Крупный релиз → по смыслу **minor** (`2.0.x` → `2.1.0-SAC`)
|
||||
|
||||
## Другие скрипты с собственной версией
|
||||
|
||||
- **`Exchange-MailSecurity.ps1`** — отдельный `$ScriptVersion`; bump только если меняли этот пакет
|
||||
- После bump опубликовать на шару NETLOGON (`update-rdp-monitor.ps1` / pull на сервере публикации)
|
||||
|
||||
## Чеклист перед push
|
||||
|
||||
- [ ] `$ScriptVersion` и `version.txt` совпадают
|
||||
- [ ] Версия на шаре будет **новее** локальной `deployed_version.txt` на клиентах
|
||||
@@ -1,28 +0,0 @@
|
||||
---
|
||||
description: При правке ssh-monitor обязательно поднимать version.txt и SSH_MONITOR_VERSION
|
||||
globs: ssh-monitor,sac-client.sh,update_ssh_monitor.sh,version.txt
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Bump версии ssh-monitor
|
||||
|
||||
Без bump **`update_ssh_monitor.sh` не скопирует новый скрипт** на хост (сравнение sha256; версия нужна для отчётов, SAC и диагностики).
|
||||
|
||||
## Что менять в одном коммите с правкой
|
||||
|
||||
1. **`ssh-monitor`** — `SSH_MONITOR_VERSION="X.Y.Z-SAC"` (первая строка блока версии)
|
||||
2. **`version.txt`** — **та же строка**
|
||||
|
||||
Строки должны **совпадать**.
|
||||
|
||||
## Как поднимать
|
||||
|
||||
- Обычная правка → **patch**: `2.0.0-SAC` → `2.0.1-SAC`
|
||||
- Крупный релиз → **minor** по смыслу
|
||||
|
||||
`sac-client.sh` отдельный номер **не** задаёт — берёт `SSH_MONITOR_VERSION` из установленного `ssh-monitor`.
|
||||
|
||||
## Чеклист перед push
|
||||
|
||||
- [ ] `SSH_MONITOR_VERSION` и `version.txt` совпадают
|
||||
- [ ] На хостах: `git pull` и запуск `update_ssh_monitor.sh` (или `--deploy`)
|
||||
@@ -0,0 +1,2 @@
|
||||
# Shell scripts must use LF — CRLF in shebang breaks systemd (status=203/EXEC).
|
||||
*.sh text eol=lf
|
||||
@@ -21,6 +21,7 @@ dist/
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
*.code-workspace
|
||||
.cursor/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
@@ -43,3 +44,4 @@ pgdata/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
.cursorignore
|
||||
|
||||
@@ -1,85 +1,51 @@
|
||||
# Security Alert Center (SAC)
|
||||
# Security Alert Center (SAC)
|
||||
|
||||
Центральная платформа сбора, хранения и отображения событий безопасности с Linux- и Windows-хостов.
|
||||
Центральная платформа: приём событий от агентов, корреляция, UI, оповещения, управление хостами.
|
||||
|
||||
**English:** [README_en.md](README_en.md)
|
||||
|
||||
## Агенты (отдельные репозитории)
|
||||
## Репозитории
|
||||
|
||||
| Репозиторий | Назначение |
|
||||
|-------------|------------|
|
||||
| [ssh-monitor](https://git.kalinamall.ru/PapaTramp/ssh-monitor) | Linux: SSH, sudo, logind, брутфорс, баны IP |
|
||||
| [RDP-login-monitor](https://git.kalinamall.ru/PapaTramp/RDP-login-monitor) | Windows: RDP/RDS, RD Gateway, WinRM, admin share C$/ADMIN$ |
|
||||
| **security-alert-center** (этот репозиторий) | Ubuntu 24.04: API, БД, UI, оповещения |
|
||||
| [seaca](https://git.kalinamall.ru/PapaTramp/seaca) | Android: мобильный клиент SAC, push (FCM) |
|
||||
| [ssh-monitor](https://git.kalinamall.ru/PapaTramp/ssh-monitor) | Linux-агент |
|
||||
| [RDP-login-monitor](https://git.kalinamall.ru/PapaTramp/RDP-login-monitor) | Windows-агент |
|
||||
| **security-alert-center** | Сервер SAC (Ubuntu 24.04) |
|
||||
| [seaca](https://git.kalinamall.ru/PapaTramp/seaca) | Android-клиент |
|
||||
|
||||
## Статус
|
||||
**Версия:** `0.5.18` · **Деплой:** `sudo /opt/sac-deploy.sh`
|
||||
|
||||
**Версия:** `0.9.0`
|
||||
**Стек:** FastAPI, PostgreSQL, Vue 3, JWT, SSE
|
||||
**Деплой:** `sudo /opt/sac-deploy.sh` (см. `deploy/sac-deploy.sh`)
|
||||
## Возможности
|
||||
|
||||
## Скриншоты UI
|
||||
|
||||
| Обзор | События | Проблемы |
|
||||
|-------|---------|----------|
|
||||
|  |  |  |
|
||||
|
||||
## Что мониторится
|
||||
|
||||
События приходят от агентов по [контракту ingest](docs/agent-integration.md) (`type`, `severity`, `details`):
|
||||
|
||||
- **Linux — [ssh-monitor](https://git.kalinamall.ru/PapaTramp/ssh-monitor):** SSH (успех/неудача), **sudo**, **systemd-logind**, брутфорс и баны IP (ipset), пороги без бана, ежедневный отчёт, heartbeat.
|
||||
- **Windows — [RDP-login-monitor](https://git.kalinamall.ru/PapaTramp/RDP-login-monitor):**
|
||||
- **RDP/RDS** (4624/4625);
|
||||
- **RD Gateway** (302/303), **WinRM / PowerShell Remoting** (Enter-PSSession, ID 91);
|
||||
- **админ-шары `C$` / `ADMIN$`** (Security **5140**, `smb.admin_share.access`);
|
||||
- **RDS Shadow Control** (RCM 20506/20507/20510), блокировка учётки **4740**, ежедневный отчёт, heartbeat, **инвентаризация железа** (`agent.inventory`).
|
||||
- **В SAC:** problems (корреляция), Telegram/email/webhook, лимит входа в UI, SSE-дашборд и live-обновление хостов, статус агентов по heartbeat, карточка хоста с железом.
|
||||
|
||||
**Примеры типов:** `ssh.login.*`, `privilege.sudo.command`, `rdp.login.*`, `winrm.session.started`, `smb.admin_share.access`, `agent.heartbeat`, `agent.inventory`, `report.daily.*` — [полная таблица](docs/agent-integration.md#3-маппинг-уведомлений--типы-событий).
|
||||
|
||||
## Возможности платформы
|
||||
|
||||
- Роли `admin` / `monitor`, управление пользователями в UI, JWT с проверкой активности в БД
|
||||
- События, хосты, problems, dashboard, отчёты, live SSE
|
||||
- Оповещения: Telegram, email, webhook; правила problems и cooldown
|
||||
- Защита SPA, `DELETE /hosts` только для admin, login rate limit, DOMPurify для `report_html`
|
||||
- Страницы ошибок `401` / `403` / `404` / `429`
|
||||
- Статистика системы в sidebar (CPU, RAM, диск, latency БД)
|
||||
- **Ingest** событий от агентов ([контракт](docs/agent-integration.md)): SSH/sudo/logind, RDP/RDG/WinRM/SMB, heartbeat, отчёты, inventory
|
||||
- **Problems** — автокорреляция (в т.ч. RDG 302→303 flap за 1–10 с)
|
||||
- **Оповещения** — Telegram, email, webhook, FCM (Seaca); severity + cooldown
|
||||
- **UI** — события, хосты, problems, отчёты, обзор (SSE live), настройки (admin)
|
||||
- **RDG flap** — бейдж на событиях 302/303, **qwinsta/logoff** по WinRM на клиентский ПК (нужен domain admin в настройках); опционально — автоотключение зависшей сессии при flap
|
||||
- **Хосты** — статус агента, inventory, обновление ssh-monitor/RDP (SSH/WinRM), тест Git/SSH/WinRM; WinRM RDP: SAC тянет пакет с git, клиент скачивает zip с SAC, локальный `Deploy-LoginMonitor.ps1` (git на ПК не нужен)
|
||||
- **Мобильные** — enrollment, устройства, push; Seaca: ack/resolve, qwinsta/logoff через API SAC
|
||||
- **Роли** — `admin` / `monitor`; JWT, rate limit входа
|
||||
- **Безопасность (0.5.0)** — проверка host key SSH, SSE через httpOnly-cookie, защита rate limit от спуфинга `X-Forwarded-For`, `SAC_SECURITY_ENFORCE`, HMAC для API-ключей
|
||||
|
||||
## Документация
|
||||
|
||||
| Документ | Описание |
|
||||
|----------|----------|
|
||||
| [docs/TZ.md](docs/TZ.md) | Техническое задание |
|
||||
| [docs/architecture.md](docs/architecture.md) | Архитектура |
|
||||
| [docs/agent-integration.md](docs/agent-integration.md) | Интеграция агентов, режим `UseSAC` |
|
||||
| [docs/event-schema-v1.json](docs/event-schema-v1.json) | JSON Schema событий v1 |
|
||||
| [docs/install-ubuntu-24.04-native.md](docs/install-ubuntu-24.04-native.md) | **Ubuntu 24.04 — native (основной)** |
|
||||
| [docs/install-ubuntu-24.04-docker.md](docs/install-ubuntu-24.04-docker.md) | Ubuntu 24.04 — Docker |
|
||||
| [docs/deployment.md](docs/deployment.md) | Развёртывание и эксплуатация |
|
||||
| [docs/ssl-certificate.md](docs/ssl-certificate.md) | TLS-сертификат |
|
||||
| [docs/runbook-ops.md](docs/runbook-ops.md) | Runbook эксплуатации |
|
||||
| [docs/agent-integration.md](docs/agent-integration.md) | Агенты, `UseSAC`, типы событий |
|
||||
| [docs/agent-control-plane.md](docs/agent-control-plane.md) | RDG flap, qwinsta, обновления агентов |
|
||||
| [docs/install-ubuntu-24.04-native.md](docs/install-ubuntu-24.04-native.md) | Установка (native) |
|
||||
| [docs/deployment.md](docs/deployment.md) | Эксплуатация |
|
||||
| [docs/seaca-mobile.md](docs/seaca-mobile.md) | Мобильные устройства |
|
||||
|
||||
## Быстрый старт (native)
|
||||
## Быстрый старт
|
||||
|
||||
```bash
|
||||
git clone https://git.kalinamall.ru/PapaTramp/security-alert-center.git /opt/security-alert-center
|
||||
# deploy/env.native.example -> config/sac-api.env
|
||||
cd /opt/security-alert-center/backend
|
||||
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
|
||||
.venv/bin/alembic upgrade head
|
||||
# deploy/env.native.example → config/sac-api.env (JWT_SECRET, CORS_ORIGINS, SAC_SECURITY_ENFORCE)
|
||||
cd /opt/security-alert-center/backend && python3 -m venv .venv
|
||||
.venv/bin/pip install -r requirements.txt && .venv/bin/alembic upgrade head
|
||||
```
|
||||
|
||||
Полная инструкция: [docs/install-ubuntu-24.04-native.md](docs/install-ubuntu-24.04-native.md)
|
||||
|
||||
## Целевая платформа
|
||||
|
||||
- **Сервер SAC:** Ubuntu 24.04 LTS
|
||||
- **Агенты:** ssh-monitor (Linux), RDP-login-monitor (Windows)
|
||||
|
||||
## Лицензия
|
||||
|
||||
MIT — см. [LICENSE](LICENSE).
|
||||
Copyright (c) 2026 Andrey «PapaTramp» Lutsenko.
|
||||
MIT — [LICENSE](LICENSE).
|
||||
|
||||
+28
-66
@@ -1,88 +1,50 @@
|
||||
# Security Alert Center (SAC)
|
||||
# Security Alert Center (SAC)
|
||||
|
||||
Self-hosted hub for collecting, storing, and displaying security events from Linux and Windows hosts.
|
||||
Self-hosted hub for security events from Linux and Windows agents: ingest, correlation, UI, notifications, host management.
|
||||
|
||||
**Русский:** [README.md](README.md)
|
||||
|
||||
## Agents (separate repositories)
|
||||
## Repositories
|
||||
|
||||
| Repository | Role |
|
||||
|------------|------|
|
||||
| [ssh-monitor](https://github.com/PTah/ssh-monitor) | Linux: SSH, sudo, logind, brute-force, IP ban |
|
||||
| [RDP-login-monitor](https://github.com/PTah/RDP-login-monitor) | Windows: RDP/RDS, RD Gateway, WinRM, admin share C$/ADMIN$ |
|
||||
| **security-alert-center** (this repo) | Ubuntu 24.04: API, database, UI, notifications |
|
||||
| [ssh-monitor](https://git.kalinamall.ru/PapaTramp/ssh-monitor) | Linux agent |
|
||||
| [RDP-login-monitor](https://git.kalinamall.ru/PapaTramp/RDP-login-monitor) | Windows agent |
|
||||
| **security-alert-center** | SAC server (Ubuntu 24.04) |
|
||||
| [seaca](https://git.kalinamall.ru/PapaTramp/seaca) | Android client |
|
||||
|
||||
## Status
|
||||
**Version:** `0.5.18` · **Deploy:** `sudo /opt/sac-deploy.sh`
|
||||
|
||||
**Version:** `0.8.3`
|
||||
**Stack:** FastAPI, PostgreSQL, Vue 3, JWT, SSE
|
||||
**Deploy:** `sudo /opt/sac-deploy.sh` (see `deploy/sac-deploy.sh`)
|
||||
## Features
|
||||
|
||||
## UI screenshots
|
||||
|
||||
| Overview | Events | Problems |
|
||||
|----------|--------|----------|
|
||||
|  |  |  |
|
||||
|
||||
## What is monitored
|
||||
|
||||
Agents send events via the [ingest contract](docs/agent-integration.md) (`type`, `severity`, `details`):
|
||||
|
||||
- **Linux — [ssh-monitor](https://github.com/PTah/ssh-monitor):** SSH success/failure, **sudo**, **systemd-logind**, brute-force and IP ban (ipset), thresholds without ban, daily report, heartbeat.
|
||||
- **Windows — [RDP-login-monitor](https://github.com/PTah/RDP-login-monitor):**
|
||||
- **RDP/RDS** (4624/4625);
|
||||
- **RD Gateway** (302/303), **WinRM / PowerShell Remoting** (Enter-PSSession, event ID 91);
|
||||
- **admin shares `C$` / `ADMIN$`** (Security **5140**, `smb.admin_share.access`);
|
||||
- **RDS Shadow Control** (RCM 20506/20507/20510), account lockout **4740**, daily report, heartbeat, **hardware inventory** (`agent.inventory`).
|
||||
- **SAC platform:** problems (correlation), Telegram/email/webhook, UI login rate limit, SSE dashboard and live host updates, agent online/stale by heartbeat, host detail with hardware snapshot.
|
||||
|
||||
**Event type examples:** `ssh.login.*`, `privilege.sudo.command`, `rdp.login.*`, `winrm.session.started`, `smb.admin_share.access`, `agent.heartbeat`, `agent.inventory`, `report.daily.*` — [full mapping](docs/agent-integration.md#3-маппинг-уведомлений--типы-событий).
|
||||
|
||||
## Platform features
|
||||
|
||||
- Roles `admin` / `monitor`, user management in UI, JWT validated against DB
|
||||
- Events, hosts, problems, dashboard, reports, live SSE
|
||||
- Notifications: Telegram, email, webhook; problem rules and cooldowns
|
||||
- SPA hardening, admin-only `DELETE /hosts`, login rate limit, DOMPurify for `report_html`
|
||||
- Custom error pages: `401` / `403` / `404` / `429`
|
||||
- Sidebar system stats (CPU, RAM, disk, DB latency)
|
||||
- **Ingest** from agents ([contract](docs/agent-integration.md)): SSH/sudo/logind, RDP/RDG/WinRM/SMB, heartbeat, reports, inventory
|
||||
- **Problems** — auto-correlation (incl. RDG 302→303 flap within 1–10 s)
|
||||
- **Notifications** — Telegram, email, webhook, FCM (Seaca)
|
||||
- **Web UI** — events, hosts, problems, reports, dashboard (SSE), settings (admin)
|
||||
- **RDG flap** — badge on 302/303 events, **qwinsta/logoff** via WinRM to client PC (domain admin required); optional auto-disconnect of stuck sessions
|
||||
- **Hosts** — agent status, inventory, agent updates (SSH/WinRM); WinRM RDP: SAC fetches from git, client downloads zip from SAC, runs local `Deploy-LoginMonitor.ps1` (no git on PC)
|
||||
- **Mobile** — enrollment, devices, push; Seaca: ack/resolve, qwinsta/logoff via SAC API
|
||||
- **Roles** — `admin` / `monitor`
|
||||
- **Security (0.5.0)** — SSH host-key verification, SSE via httpOnly cookie, anti-spoof login rate limit, `SAC_SECURITY_ENFORCE`, HMAC API keys
|
||||
|
||||
## Documentation
|
||||
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| [docs/TZ.md](docs/TZ.md) | Technical specification (Russian) |
|
||||
| [docs/architecture.md](docs/architecture.md) | Architecture |
|
||||
| [docs/agent-integration.md](docs/agent-integration.md) | Agent integration (`UseSAC` modes) |
|
||||
| [docs/event-schema-v1.json](docs/event-schema-v1.json) | Event JSON Schema v1 |
|
||||
| [docs/install-ubuntu-24.04-native.md](docs/install-ubuntu-24.04-native.md) | **Ubuntu 24.04 native install** |
|
||||
| [docs/install-ubuntu-24.04-docker.md](docs/install-ubuntu-24.04-docker.md) | Docker alternative |
|
||||
| [docs/deployment.md](docs/deployment.md) | Deployment and operations |
|
||||
| [docs/ssl-certificate.md](docs/ssl-certificate.md) | TLS certificate setup |
|
||||
| [docs/runbook-ops.md](docs/runbook-ops.md) | Operations runbook |
|
||||
| [docs/agent-integration.md](docs/agent-integration.md) | Agents, `UseSAC`, event types |
|
||||
| [docs/agent-control-plane.md](docs/agent-control-plane.md) | RDG flap, qwinsta, agent updates |
|
||||
| [docs/install-ubuntu-24.04-native.md](docs/install-ubuntu-24.04-native.md) | Native install |
|
||||
| [docs/deployment.md](docs/deployment.md) | Operations |
|
||||
|
||||
## Quick start (native)
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
git clone https://github.com/PTah/security-alert-center.git /opt/security-alert-center
|
||||
# deploy/env.native.example -> config/sac-api.env
|
||||
cd /opt/security-alert-center/backend
|
||||
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
|
||||
.venv/bin/alembic upgrade head
|
||||
git clone https://git.kalinamall.ru/PapaTramp/security-alert-center.git /opt/security-alert-center
|
||||
# deploy/env.native.example → config/sac-api.env
|
||||
cd /opt/security-alert-center/backend && python3 -m venv .venv
|
||||
.venv/bin/pip install -r requirements.txt && .venv/bin/alembic upgrade head
|
||||
```
|
||||
|
||||
Full guide: [docs/install-ubuntu-24.04-native.md](docs/install-ubuntu-24.04-native.md)
|
||||
|
||||
## Target platform
|
||||
|
||||
- **SAC server:** Ubuntu 24.04 LTS
|
||||
- **Agents:** ssh-monitor (Linux), RDP-login-monitor (Windows)
|
||||
|
||||
## GitHub topics
|
||||
|
||||
`security-monitoring`, `siem`, `incident-response`, `fastapi`, `vue`, `postgresql`, `telegram-bot`, `webhook`, `jwt`, `rbac`, `self-hosted`
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
Copyright (c) 2026 Andrey "PapaTramp" Lutsenko.
|
||||
MIT — [LICENSE](LICENSE).
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Revision ID: 016
|
||||
Revises: 015
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "016"
|
||||
down_revision: Union[str, None] = "015"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"agent_commands",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("command_uuid", sa.String(length=36), nullable=False),
|
||||
sa.Column("host_id", sa.Integer(), nullable=False),
|
||||
sa.Column("event_id", sa.Integer(), nullable=True),
|
||||
sa.Column("command_type", sa.String(length=32), nullable=False),
|
||||
sa.Column("params", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||
sa.Column("status", sa.String(length=16), nullable=False, server_default="pending"),
|
||||
sa.Column("result_stdout", sa.Text(), nullable=True),
|
||||
sa.Column("result_stderr", sa.Text(), nullable=True),
|
||||
sa.Column("requested_by", sa.String(length=128), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["event_id"], ["events.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["host_id"], ["hosts.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("command_uuid"),
|
||||
)
|
||||
op.create_index("ix_agent_commands_command_uuid", "agent_commands", ["command_uuid"])
|
||||
op.create_index("ix_agent_commands_host_id", "agent_commands", ["host_id"])
|
||||
op.create_index("ix_agent_commands_status", "agent_commands", ["status"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_agent_commands_status", table_name="agent_commands")
|
||||
op.drop_index("ix_agent_commands_host_id", table_name="agent_commands")
|
||||
op.drop_index("ix_agent_commands_command_uuid", table_name="agent_commands")
|
||||
op.drop_table("agent_commands")
|
||||
@@ -0,0 +1,25 @@
|
||||
"""ui_settings: Windows domain admin for agent commands
|
||||
|
||||
Revision ID: 017
|
||||
Revises: 016
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "017"
|
||||
down_revision: Union[str, None] = "016"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("ui_settings", sa.Column("win_admin_user", sa.String(256), nullable=True))
|
||||
op.add_column("ui_settings", sa.Column("win_admin_password", sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("ui_settings", "win_admin_password")
|
||||
op.drop_column("ui_settings", "win_admin_user")
|
||||
@@ -0,0 +1,25 @@
|
||||
"""ui_settings: Linux SSH admin for remote agent updates
|
||||
|
||||
Revision ID: 018
|
||||
Revises: 017
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "018"
|
||||
down_revision: Union[str, None] = "017"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("ui_settings", sa.Column("linux_admin_user", sa.String(128), nullable=True))
|
||||
op.add_column("ui_settings", sa.Column("linux_admin_password", sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("ui_settings", "linux_admin_password")
|
||||
op.drop_column("ui_settings", "linux_admin_user")
|
||||
@@ -0,0 +1,25 @@
|
||||
"""hosts: persist Linux SSH admin check status
|
||||
|
||||
Revision ID: 019
|
||||
Revises: 018
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "019"
|
||||
down_revision: Union[str, None] = "018"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("hosts", sa.Column("ssh_admin_ok", sa.Boolean(), nullable=True))
|
||||
op.add_column("hosts", sa.Column("ssh_admin_checked_at", sa.DateTime(timezone=True), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("hosts", "ssh_admin_checked_at")
|
||||
op.drop_column("hosts", "ssh_admin_ok")
|
||||
@@ -0,0 +1,98 @@
|
||||
"""agent control plane: host update/config + ui agent-update settings
|
||||
|
||||
Revision ID: 020
|
||||
Revises: 019
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
revision: str = "020"
|
||||
down_revision: Union[str, None] = "019"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"hosts",
|
||||
sa.Column("pending_agent_update", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
)
|
||||
op.add_column(
|
||||
"hosts",
|
||||
sa.Column("pending_update_requested_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"hosts",
|
||||
sa.Column("pending_update_target_version", sa.String(length=64), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"hosts",
|
||||
sa.Column("agent_config", JSONB, nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"hosts",
|
||||
sa.Column("agent_config_revision", sa.Integer(), nullable=False, server_default="0"),
|
||||
)
|
||||
op.add_column(
|
||||
"hosts",
|
||||
sa.Column("agent_update_state", sa.String(length=32), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"hosts",
|
||||
sa.Column("agent_update_last_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.add_column("hosts", sa.Column("agent_update_last_error", sa.Text(), nullable=True))
|
||||
|
||||
op.add_column(
|
||||
"ui_settings",
|
||||
sa.Column("agent_update_mode", sa.String(length=16), nullable=False, server_default="gpo"),
|
||||
)
|
||||
op.add_column(
|
||||
"ui_settings",
|
||||
sa.Column("agent_update_fallback_enabled", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
)
|
||||
op.add_column(
|
||||
"ui_settings",
|
||||
sa.Column("agent_update_fallback_minutes", sa.Integer(), nullable=False, server_default="15"),
|
||||
)
|
||||
op.add_column(
|
||||
"ui_settings",
|
||||
sa.Column("agent_recommended_rdp_version", sa.String(length=64), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"ui_settings",
|
||||
sa.Column("agent_recommended_ssh_version", sa.String(length=64), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"ui_settings",
|
||||
sa.Column("agent_min_rdp_version", sa.String(length=64), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"ui_settings",
|
||||
sa.Column("agent_min_ssh_version", sa.String(length=64), nullable=True),
|
||||
)
|
||||
op.add_column("ui_settings", sa.Column("win_agent_update_script", sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("ui_settings", "win_agent_update_script")
|
||||
op.drop_column("ui_settings", "agent_min_ssh_version")
|
||||
op.drop_column("ui_settings", "agent_min_rdp_version")
|
||||
op.drop_column("ui_settings", "agent_recommended_ssh_version")
|
||||
op.drop_column("ui_settings", "agent_recommended_rdp_version")
|
||||
op.drop_column("ui_settings", "agent_update_fallback_minutes")
|
||||
op.drop_column("ui_settings", "agent_update_fallback_enabled")
|
||||
op.drop_column("ui_settings", "agent_update_mode")
|
||||
|
||||
op.drop_column("hosts", "agent_update_last_error")
|
||||
op.drop_column("hosts", "agent_update_last_at")
|
||||
op.drop_column("hosts", "agent_update_state")
|
||||
op.drop_column("hosts", "agent_config_revision")
|
||||
op.drop_column("hosts", "agent_config")
|
||||
op.drop_column("hosts", "pending_update_target_version")
|
||||
op.drop_column("hosts", "pending_update_requested_at")
|
||||
op.drop_column("hosts", "pending_agent_update")
|
||||
@@ -0,0 +1,51 @@
|
||||
"""agent git release repos and version cache
|
||||
|
||||
Revision ID: 021
|
||||
Revises: 020
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "021"
|
||||
down_revision: Union[str, None] = "020"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"ui_settings",
|
||||
sa.Column("agent_rdp_git_repo_url", sa.String(length=512), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"ui_settings",
|
||||
sa.Column("agent_ssh_git_repo_url", sa.String(length=512), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"ui_settings",
|
||||
sa.Column("agent_git_branch", sa.String(length=64), nullable=False, server_default="main"),
|
||||
)
|
||||
op.add_column(
|
||||
"ui_settings",
|
||||
sa.Column("agent_git_rdp_version", sa.String(length=64), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"ui_settings",
|
||||
sa.Column("agent_git_ssh_version", sa.String(length=64), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"ui_settings",
|
||||
sa.Column("agent_git_fetched_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("ui_settings", "agent_git_fetched_at")
|
||||
op.drop_column("ui_settings", "agent_git_ssh_version")
|
||||
op.drop_column("ui_settings", "agent_git_rdp_version")
|
||||
op.drop_column("ui_settings", "agent_git_branch")
|
||||
op.drop_column("ui_settings", "agent_ssh_git_repo_url")
|
||||
op.drop_column("ui_settings", "agent_rdp_git_repo_url")
|
||||
@@ -0,0 +1,27 @@
|
||||
"""host remote_action JSON for background SSH/WinRM jobs
|
||||
|
||||
Revision ID: 022_host_remote_action
|
||||
Revises: 021
|
||||
Create Date: 2026-06-21
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "022_host_remote_action"
|
||||
down_revision = "021"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"hosts",
|
||||
sa.Column("remote_action", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("hosts", "remote_action")
|
||||
@@ -0,0 +1,34 @@
|
||||
"""event type visibility settings
|
||||
|
||||
Revision ID: 023_event_type_visibility
|
||||
Revises: 022_host_remote_action
|
||||
Create Date: 2026-06-21
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "023_event_type_visibility"
|
||||
down_revision = "022_host_remote_action"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"event_type_visibility",
|
||||
sa.Column("event_type", sa.String(length=128), nullable=False),
|
||||
sa.Column("show_in_events", sa.Boolean(), nullable=False),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.PrimaryKeyConstraint("event_type"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("event_type_visibility")
|
||||
@@ -0,0 +1,28 @@
|
||||
"""login security whitelist in ui_settings
|
||||
|
||||
Revision ID: 024_login_security
|
||||
Revises: 023_event_type_visibility
|
||||
Create Date: 2026-06-25
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "024_login_security"
|
||||
down_revision = "023_event_type_visibility"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("ui_settings", sa.Column("login_ip_whitelist", sa.Text(), nullable=True))
|
||||
op.add_column(
|
||||
"ui_settings",
|
||||
sa.Column("login_sync_fail2ban", sa.Boolean(), server_default="false", nullable=False),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("ui_settings", "login_sync_fail2ban")
|
||||
op.drop_column("ui_settings", "login_ip_whitelist")
|
||||
@@ -0,0 +1,26 @@
|
||||
"""auto RDP flap disconnect setting in ui_settings
|
||||
|
||||
Revision ID: 025_auto_rdp_flap_disconnect
|
||||
Revises: 024_login_security
|
||||
Create Date: 2026-07-02
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "025_auto_rdp_flap_disconnect"
|
||||
down_revision = "024_login_security"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"ui_settings",
|
||||
sa.Column("auto_rdp_flap_disconnect", sa.Boolean(), server_default="false", nullable=False),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("ui_settings", "auto_rdp_flap_disconnect")
|
||||
@@ -0,0 +1,74 @@
|
||||
"""dedupe host_silence problems + unique active index
|
||||
|
||||
Revision ID: 026_host_silence_dedupe
|
||||
Revises: 025_auto_rdp_flap_disconnect
|
||||
Create Date: 2026-07-03
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "026_host_silence_dedupe"
|
||||
down_revision = "025_auto_rdp_flap_disconnect"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _dedupe_host_silence_problems() -> None:
|
||||
op.execute(
|
||||
"""
|
||||
WITH ranked AS (
|
||||
SELECT
|
||||
p.id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY p.host_id
|
||||
ORDER BY p.last_seen_at DESC, p.id DESC
|
||||
) AS rn
|
||||
FROM problems p
|
||||
WHERE p.rule_id = 'rule:host_silence'
|
||||
AND p.status IN ('open', 'acknowledged')
|
||||
)
|
||||
UPDATE problems
|
||||
SET status = 'resolved',
|
||||
resolved_by = 'auto',
|
||||
updated_at = NOW()
|
||||
WHERE id IN (SELECT id FROM ranked WHERE rn > 1)
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
WITH ranked AS (
|
||||
SELECT
|
||||
p.id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY lower(h.hostname)
|
||||
ORDER BY p.last_seen_at DESC, p.id DESC
|
||||
) AS rn
|
||||
FROM problems p
|
||||
JOIN hosts h ON h.id = p.host_id
|
||||
WHERE p.rule_id = 'rule:host_silence'
|
||||
AND p.status IN ('open', 'acknowledged')
|
||||
)
|
||||
UPDATE problems
|
||||
SET status = 'resolved',
|
||||
resolved_by = 'auto',
|
||||
updated_at = NOW()
|
||||
WHERE id IN (SELECT id FROM ranked WHERE rn > 1)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
_dedupe_host_silence_problems()
|
||||
op.execute(
|
||||
"""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_problems_host_silence_active
|
||||
ON problems (host_id)
|
||||
WHERE rule_id = 'rule:host_silence'
|
||||
AND status IN ('open', 'acknowledged')
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS uq_problems_host_silence_active")
|
||||
@@ -0,0 +1,25 @@
|
||||
"""per-host management credentials (SSH / WinRM override)
|
||||
|
||||
Revision ID: 027_host_mgmt_credentials
|
||||
Revises: 026_host_silence_dedupe
|
||||
Create Date: 2026-07-14
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "027_host_mgmt_credentials"
|
||||
down_revision = "026_host_silence_dedupe"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("hosts", sa.Column("mgmt_user", sa.String(length=256), nullable=True))
|
||||
op.add_column("hosts", sa.Column("mgmt_password", sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("hosts", "mgmt_password")
|
||||
op.drop_column("hosts", "mgmt_user")
|
||||
@@ -0,0 +1,76 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth.api_key import get_api_key_auth
|
||||
from app.database import get_db
|
||||
from app.services.agent_commands import (
|
||||
complete_command,
|
||||
resolve_host_by_agent_instance_id,
|
||||
)
|
||||
from app.services.agent_poll import build_agent_poll_payload
|
||||
|
||||
router = APIRouter(prefix="/agent", tags=["agent"])
|
||||
|
||||
|
||||
class AgentCommandResultBody(BaseModel):
|
||||
status: str = Field(pattern="^(completed|failed)$")
|
||||
stdout: str | None = None
|
||||
stderr: str | None = None
|
||||
|
||||
|
||||
class AgentCommandsPollResponse(BaseModel):
|
||||
config_revision: int = 0
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
update: dict[str, Any] = Field(default_factory=dict)
|
||||
commands: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
@router.get("/commands", response_model=AgentCommandsPollResponse)
|
||||
def poll_agent_commands(
|
||||
agent_instance_id: str = Query(..., min_length=1),
|
||||
db: Session = Depends(get_db),
|
||||
_api_key: str = Depends(get_api_key_auth),
|
||||
) -> AgentCommandsPollResponse:
|
||||
host = resolve_host_by_agent_instance_id(db, agent_instance_id)
|
||||
if host is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown agent_instance_id")
|
||||
payload = build_agent_poll_payload(db, host)
|
||||
return AgentCommandsPollResponse(**payload)
|
||||
|
||||
|
||||
@router.post("/commands/{command_uuid}/result")
|
||||
def post_agent_command_result(
|
||||
command_uuid: str,
|
||||
body: AgentCommandResultBody,
|
||||
db: Session = Depends(get_db),
|
||||
_api_key: str = Depends(get_api_key_auth),
|
||||
) -> dict[str, str]:
|
||||
cmd = complete_command(
|
||||
db,
|
||||
command_uuid,
|
||||
status=body.status,
|
||||
stdout=body.stdout,
|
||||
stderr=body.stderr,
|
||||
)
|
||||
if cmd is None:
|
||||
raise HTTPException(status_code=404, detail="Command not found")
|
||||
db.commit()
|
||||
return {"status": cmd.status, "command_uuid": cmd.command_uuid}
|
||||
|
||||
|
||||
@router.get("/rdp-bundle/{token}")
|
||||
def download_rdp_bundle(token: str) -> Response:
|
||||
from app.services.rdp_bundle_delivery import get_rdp_bundle_zip
|
||||
|
||||
data = get_rdp_bundle_zip(token)
|
||||
if not data:
|
||||
raise HTTPException(status_code=404, detail="Bundle not found or expired")
|
||||
return Response(
|
||||
content=data,
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": 'attachment; filename="sac-rdp-bundle.zip"'},
|
||||
)
|
||||
+16
-82
@@ -1,156 +1,90 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
|
||||
from app.auth.jwt_auth import CurrentUser, create_access_token, get_current_user
|
||||
|
||||
from app.auth.stream_cookie import clear_stream_auth_cookie, set_stream_auth_cookie
|
||||
from app.config import get_settings
|
||||
|
||||
from app.database import get_db
|
||||
|
||||
from app.services.login_rate_limit import (
|
||||
|
||||
ensure_login_allowed,
|
||||
|
||||
record_login_failure,
|
||||
|
||||
record_login_success,
|
||||
|
||||
)
|
||||
|
||||
from app.services.user_auth import authenticate_user
|
||||
|
||||
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
|
||||
username: str
|
||||
|
||||
password: str
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
|
||||
access_token: str
|
||||
|
||||
token_type: str = "bearer"
|
||||
|
||||
username: str
|
||||
|
||||
role: str
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class MeResponse(BaseModel):
|
||||
|
||||
username: str
|
||||
|
||||
role: str
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
|
||||
def login(body: LoginRequest, request: Request, db: Session = Depends(get_db)) -> TokenResponse:
|
||||
|
||||
def login(body: LoginRequest, request: Request, db: Session = Depends(get_db)) -> JSONResponse:
|
||||
settings = get_settings()
|
||||
|
||||
if not settings.sac_admin_password and not _has_any_user(db):
|
||||
|
||||
raise HTTPException(
|
||||
|
||||
status_code=503,
|
||||
|
||||
detail="SAC UI users are not configured (run alembic upgrade and set SAC_ADMIN_PASSWORD for bootstrap)",
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
ip_address = ensure_login_allowed(db, request)
|
||||
|
||||
|
||||
|
||||
user = authenticate_user(db, body.username, body.password)
|
||||
|
||||
if user is None:
|
||||
|
||||
record_login_failure(db, ip_address=ip_address, username=body.username)
|
||||
|
||||
db.commit()
|
||||
|
||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||
|
||||
|
||||
|
||||
record_login_success(db, ip_address=ip_address, username=user.username)
|
||||
|
||||
db.commit()
|
||||
|
||||
|
||||
|
||||
token = create_access_token(user.username, user.role)
|
||||
|
||||
return TokenResponse(
|
||||
|
||||
payload = TokenResponse(
|
||||
access_token=token,
|
||||
|
||||
username=user.username,
|
||||
|
||||
role=user.role,
|
||||
|
||||
)
|
||||
response = JSONResponse(content=payload.model_dump())
|
||||
set_stream_auth_cookie(response, token, settings)
|
||||
return response
|
||||
|
||||
|
||||
|
||||
@router.post("/logout", status_code=204)
|
||||
def logout() -> Response:
|
||||
settings = get_settings()
|
||||
response = Response(status_code=204)
|
||||
clear_stream_auth_cookie(response, settings)
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/me", response_model=MeResponse)
|
||||
|
||||
def me(current_user: CurrentUser = Depends(get_current_user)) -> MeResponse:
|
||||
|
||||
return MeResponse(username=current_user.username, role=current_user.role)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _has_any_user(db: Session) -> bool:
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
|
||||
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
|
||||
try:
|
||||
|
||||
count = db.scalar(select(func.count()).select_from(User)) or 0
|
||||
|
||||
return count > 0
|
||||
|
||||
except Exception:
|
||||
|
||||
db.rollback()
|
||||
|
||||
return False
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
@@ -11,6 +11,7 @@ from app.database import get_db
|
||||
from app.models import Event, Host, Problem
|
||||
from app.schemas.list_models import EventSummary
|
||||
from app.services.event_summary import event_to_summary
|
||||
from app.services.event_type_visibility import get_hidden_event_types, visibility_type_filter
|
||||
from app.services.host_health import DAILY_REPORT_TYPES, HEARTBEAT_TYPE, count_stale_hosts
|
||||
|
||||
router = APIRouter(prefix="/dashboards", tags=["dashboards"])
|
||||
@@ -18,14 +19,19 @@ router = APIRouter(prefix="/dashboards", tags=["dashboards"])
|
||||
|
||||
def fetch_recent_events(db: Session, *, limit: int = 8) -> list[EventSummary]:
|
||||
"""Последние события по времени приёма ingest (received_at)."""
|
||||
rows = db.scalars(
|
||||
hidden = get_hidden_event_types(db)
|
||||
type_filter = visibility_type_filter(hidden)
|
||||
stmt = (
|
||||
select(Event)
|
||||
.join(Host)
|
||||
.options(joinedload(Event.host))
|
||||
.order_by(Event.received_at.desc())
|
||||
.limit(limit)
|
||||
).all()
|
||||
return [event_to_summary(e) for e in rows]
|
||||
)
|
||||
if type_filter is not None:
|
||||
stmt = stmt.where(type_filter)
|
||||
rows = db.scalars(stmt).all()
|
||||
return [event_to_summary(e, db) for e in rows]
|
||||
|
||||
|
||||
class TopHostItem(BaseModel):
|
||||
@@ -61,10 +67,13 @@ def dashboard_summary(
|
||||
_user: str = Depends(get_current_user),
|
||||
) -> DashboardSummary:
|
||||
since = datetime.now(timezone.utc) - timedelta(hours=24)
|
||||
hidden = get_hidden_event_types(db)
|
||||
type_filter = visibility_type_filter(hidden)
|
||||
|
||||
events_24h = db.scalar(
|
||||
select(func.count()).select_from(Event).where(Event.received_at >= since)
|
||||
) or 0
|
||||
events_24h_stmt = select(func.count()).select_from(Event).where(Event.received_at >= since)
|
||||
if type_filter is not None:
|
||||
events_24h_stmt = events_24h_stmt.where(type_filter)
|
||||
events_24h = db.scalar(events_24h_stmt) or 0
|
||||
hosts_total = db.scalar(select(func.count()).select_from(Host)) or 0
|
||||
settings = get_settings()
|
||||
hosts_stale = count_stale_hosts(db, settings.sac_heartbeat_stale_minutes)
|
||||
@@ -100,12 +109,16 @@ def dashboard_summary(
|
||||
or 0
|
||||
)
|
||||
|
||||
top_host_rows = db.execute(
|
||||
top_host_stmt = (
|
||||
select(Host.id, Host.hostname, Host.display_name, func.count())
|
||||
.select_from(Event)
|
||||
.join(Host, Event.host_id == Host.id)
|
||||
.where(Event.received_at >= since)
|
||||
.group_by(Host.id, Host.hostname, Host.display_name)
|
||||
)
|
||||
if type_filter is not None:
|
||||
top_host_stmt = top_host_stmt.where(type_filter)
|
||||
top_host_rows = db.execute(
|
||||
top_host_stmt.group_by(Host.id, Host.hostname, Host.display_name)
|
||||
.order_by(func.count().desc())
|
||||
.limit(10)
|
||||
).all()
|
||||
@@ -113,20 +126,18 @@ def dashboard_summary(
|
||||
TopHostItem(host_id=row[0], hostname=row[1], display_name=row[2], count=row[3]) for row in top_host_rows
|
||||
]
|
||||
|
||||
top_type_stmt = select(Event.type, func.count()).where(Event.received_at >= since)
|
||||
if type_filter is not None:
|
||||
top_type_stmt = top_type_stmt.where(type_filter)
|
||||
top_type_rows = db.execute(
|
||||
select(Event.type, func.count())
|
||||
.where(Event.received_at >= since)
|
||||
.group_by(Event.type)
|
||||
.order_by(func.count().desc())
|
||||
.limit(10)
|
||||
top_type_stmt.group_by(Event.type).order_by(func.count().desc()).limit(10)
|
||||
).all()
|
||||
top_event_types = [TopTypeItem(type=row[0], count=row[1]) for row in top_type_rows]
|
||||
|
||||
severity_rows = db.execute(
|
||||
select(Event.severity, func.count())
|
||||
.where(Event.received_at >= since)
|
||||
.group_by(Event.severity)
|
||||
).all()
|
||||
severity_stmt = select(Event.severity, func.count()).where(Event.received_at >= since)
|
||||
if type_filter is not None:
|
||||
severity_stmt = severity_stmt.where(type_filter)
|
||||
severity_rows = db.execute(severity_stmt.group_by(Event.severity)).all()
|
||||
severity_24h = {row[0]: row[1] for row in severity_rows}
|
||||
|
||||
recent_events = fetch_recent_events(db, limit=8)
|
||||
|
||||
+216
-16
@@ -1,22 +1,45 @@
|
||||
import logging
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, BackgroundTasks, Body, Depends, HTTPException, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from app.auth.api_key import get_api_key_auth
|
||||
from app.auth.jwt_auth import get_current_user
|
||||
from app.auth.jwt_auth import get_current_user, CurrentUser
|
||||
from app.config import get_settings
|
||||
from app.database import get_db
|
||||
from app.models import Event, Host
|
||||
from app.schemas.list_models import EventDetail, EventListResponse, EventSummary
|
||||
from app.utils.sql_like import escape_ilike_pattern
|
||||
from app.services.agent_commands import (
|
||||
command_response_fields,
|
||||
command_to_dict,
|
||||
get_command_by_uuid,
|
||||
)
|
||||
from app.services.rdg_winrm_actions import execute_logoff_via_winrm, execute_qwinsta_via_winrm
|
||||
from app.services.host_sessions import (
|
||||
event_session_id,
|
||||
event_session_terminated,
|
||||
event_supports_session_terminate,
|
||||
mark_event_session_terminated,
|
||||
terminate_session_for_event,
|
||||
)
|
||||
from app.services.linux_admin_settings import get_effective_linux_admin_for_host
|
||||
from app.services.ssh_connect import (
|
||||
HostNotLinuxError as SshHostNotLinuxError,
|
||||
HostTargetMissingError as SshHostTargetMissingError,
|
||||
)
|
||||
from app.services.win_admin_settings import get_effective_win_admin_for_host
|
||||
from app.services.winrm_connect import HostNotWindowsError, HostTargetMissingError
|
||||
from app.services.ingest import ingest_event
|
||||
from app.services.event_summary import event_to_summary
|
||||
from app.services.event_type_visibility import get_hidden_event_types, visibility_type_filter
|
||||
from app.services.problems import maybe_create_problem
|
||||
from app.services.rdp_flap_auto_disconnect import maybe_auto_disconnect_stuck_rdp_session
|
||||
from app.services.schema_validate import validate_event_payload
|
||||
from app.services.notify_dispatch import (
|
||||
AUTH_LOGIN_SUCCESS_TYPES,
|
||||
@@ -29,6 +52,9 @@ from app.services.notify_dispatch import (
|
||||
notify_lifecycle,
|
||||
notify_problem,
|
||||
notify_rdg_connection,
|
||||
schedule_notify_auth_login,
|
||||
schedule_notify_daily_report,
|
||||
schedule_notify_lifecycle,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/events", tags=["events"])
|
||||
@@ -57,6 +83,7 @@ def _ingest_response(event: Event, *, created: bool) -> IngestResponse:
|
||||
|
||||
@router.post("", response_model=IngestResponse)
|
||||
def post_event(
|
||||
background_tasks: BackgroundTasks,
|
||||
payload: dict[str, Any] = Body(...),
|
||||
db: Session = Depends(get_db),
|
||||
_api_key: str = Depends(get_api_key_auth),
|
||||
@@ -74,14 +101,18 @@ def post_event(
|
||||
event, created = ingest_event(db, payload)
|
||||
problem = None
|
||||
problem_created = False
|
||||
deferred_daily_report_id: int | None = None
|
||||
deferred_lifecycle_id: int | None = None
|
||||
deferred_auth_login_id: int | None = None
|
||||
if created:
|
||||
problem, problem_created = maybe_create_problem(db, event)
|
||||
maybe_auto_disconnect_stuck_rdp_session(db, event)
|
||||
if event.type in DAILY_REPORT_EVENT_TYPES:
|
||||
notify_daily_report(event, db=db)
|
||||
deferred_daily_report_id = event.id
|
||||
elif event.type == LIFECYCLE_EVENT_TYPE:
|
||||
notify_lifecycle(event, db=db)
|
||||
deferred_lifecycle_id = event.id
|
||||
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:
|
||||
notify_rdg_connection(event, db=db)
|
||||
else:
|
||||
@@ -93,6 +124,13 @@ def post_event(
|
||||
logger.info("ingest duplicate event_id=%s", event.event_id)
|
||||
db.commit()
|
||||
|
||||
if deferred_daily_report_id is not None:
|
||||
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)
|
||||
if problem is not None:
|
||||
body.problem_id = problem.id
|
||||
@@ -122,28 +160,43 @@ def list_events(
|
||||
type: str | None = None,
|
||||
host_id: int | None = None,
|
||||
hostname: str | None = None,
|
||||
include_hidden: bool = False,
|
||||
from_time: str | None = Query(None, alias="from"),
|
||||
to_time: str | None = Query(None, alias="to"),
|
||||
q: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
_user: str = Depends(get_current_user),
|
||||
) -> EventListResponse:
|
||||
if include_hidden and host_id is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="include_hidden requires host_id",
|
||||
)
|
||||
hidden = get_hidden_event_types(db)
|
||||
type_filter = visibility_type_filter(hidden)
|
||||
if include_hidden and host_id is not None:
|
||||
type_filter = None
|
||||
stmt = select(Event).join(Host).options(joinedload(Event.host))
|
||||
count_stmt = select(func.count()).select_from(Event).join(Host)
|
||||
if type_filter is not None:
|
||||
stmt = stmt.where(type_filter)
|
||||
count_stmt = count_stmt.where(type_filter)
|
||||
|
||||
if severity:
|
||||
stmt = stmt.where(Event.severity == severity)
|
||||
count_stmt = count_stmt.where(Event.severity == severity)
|
||||
if type:
|
||||
stmt = stmt.where(Event.type == type)
|
||||
count_stmt = count_stmt.where(Event.type == type)
|
||||
normalized = type.strip()
|
||||
if normalized:
|
||||
stmt = stmt.where(Event.type.ilike(f"{normalized}%"))
|
||||
count_stmt = count_stmt.where(Event.type.ilike(f"{normalized}%"))
|
||||
if host_id is not None:
|
||||
stmt = stmt.where(Event.host_id == host_id)
|
||||
count_stmt = count_stmt.where(Event.host_id == host_id)
|
||||
if hostname:
|
||||
like = f"%{hostname}%"
|
||||
stmt = stmt.where(Host.hostname.ilike(like) | Host.display_name.ilike(like))
|
||||
count_stmt = count_stmt.where(Host.hostname.ilike(like) | Host.display_name.ilike(like))
|
||||
like = f"%{escape_ilike_pattern(hostname)}%"
|
||||
stmt = stmt.where(Host.hostname.ilike(like, escape="\\") | Host.display_name.ilike(like, escape="\\"))
|
||||
count_stmt = count_stmt.where(Host.hostname.ilike(like, escape="\\") | Host.display_name.ilike(like, escape="\\"))
|
||||
dt_from = _parse_optional_dt(from_time)
|
||||
dt_to = _parse_optional_dt(to_time, end_of_day=True)
|
||||
if dt_from:
|
||||
@@ -153,9 +206,9 @@ def list_events(
|
||||
stmt = stmt.where(Event.occurred_at <= dt_to)
|
||||
count_stmt = count_stmt.where(Event.occurred_at <= dt_to)
|
||||
if q:
|
||||
like = f"%{q}%"
|
||||
stmt = stmt.where(Event.summary.ilike(like) | Event.title.ilike(like))
|
||||
count_stmt = count_stmt.where(Event.summary.ilike(like) | Event.title.ilike(like))
|
||||
like = f"%{escape_ilike_pattern(q)}%"
|
||||
stmt = stmt.where(Event.summary.ilike(like, escape="\\") | Event.title.ilike(like, escape="\\"))
|
||||
count_stmt = count_stmt.where(Event.summary.ilike(like, escape="\\") | Event.title.ilike(like, escape="\\"))
|
||||
|
||||
total = db.scalar(count_stmt) or 0
|
||||
rows = db.scalars(
|
||||
@@ -164,10 +217,157 @@ def list_events(
|
||||
.limit(page_size)
|
||||
).all()
|
||||
|
||||
items = [event_to_summary(e) for e in rows]
|
||||
items = [event_to_summary(e, db) for e in rows]
|
||||
return EventListResponse(items=items, total=total, page=page, page_size=page_size)
|
||||
|
||||
|
||||
class AgentCommandResponse(BaseModel):
|
||||
command_uuid: str
|
||||
command_type: str
|
||||
status: str
|
||||
result_stdout: str | None = None
|
||||
result_stderr: str | None = None
|
||||
created_at: str | None = None
|
||||
completed_at: str | None = None
|
||||
target: str | None = None
|
||||
client_hostname: str | None = None
|
||||
internal_ip: str | None = None
|
||||
|
||||
|
||||
def _agent_command_response(cmd) -> AgentCommandResponse:
|
||||
data = command_to_dict(cmd)
|
||||
extra = command_response_fields(cmd)
|
||||
return AgentCommandResponse(
|
||||
command_uuid=data["id"],
|
||||
command_type=data["type"],
|
||||
status=data["status"],
|
||||
result_stdout=data.get("result_stdout"),
|
||||
result_stderr=data.get("result_stderr"),
|
||||
created_at=data.get("created_at"),
|
||||
completed_at=data.get("completed_at"),
|
||||
target=extra.get("target"),
|
||||
client_hostname=extra.get("client_hostname"),
|
||||
internal_ip=extra.get("internal_ip"),
|
||||
)
|
||||
|
||||
|
||||
class LogoffActionBody(BaseModel):
|
||||
session_id: int
|
||||
|
||||
|
||||
class EventSessionTerminateBody(BaseModel):
|
||||
session_id: str | None = None
|
||||
|
||||
|
||||
class EventSessionTerminateResponse(BaseModel):
|
||||
ok: bool
|
||||
message: str
|
||||
target: str | None = None
|
||||
stdout: str | None = None
|
||||
stderr: str | None = None
|
||||
|
||||
|
||||
@router.post("/{event_db_id}/actions/terminate-session", response_model=EventSessionTerminateResponse)
|
||||
def post_event_terminate_session(
|
||||
event_db_id: int,
|
||||
body: EventSessionTerminateBody | None = Body(default=None),
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> EventSessionTerminateResponse:
|
||||
event = db.scalar(
|
||||
select(Event).options(joinedload(Event.host)).where(Event.id == event_db_id)
|
||||
)
|
||||
if event is None:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
if not event_supports_session_terminate(event):
|
||||
raise HTTPException(status_code=400, detail="Event type does not support session terminate")
|
||||
if event_session_terminated(event, db=db):
|
||||
raise HTTPException(status_code=409, detail="Session already terminated for this event")
|
||||
|
||||
if event.host is None:
|
||||
raise HTTPException(status_code=400, detail="Event has no host")
|
||||
linux_cfg = get_effective_linux_admin_for_host(db, event.host)
|
||||
win_cfg = get_effective_win_admin_for_host(db, event.host)
|
||||
sid = (body.session_id if body else None) or event_session_id(event)
|
||||
|
||||
try:
|
||||
result = terminate_session_for_event(
|
||||
event,
|
||||
linux_cfg=linux_cfg,
|
||||
win_cfg=win_cfg,
|
||||
session_id=sid,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except HostNotWindowsError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except HostTargetMissingError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except SshHostNotLinuxError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except SshHostTargetMissingError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
response = EventSessionTerminateResponse(
|
||||
ok=result.ok,
|
||||
message=result.message,
|
||||
target=getattr(result, "target", None),
|
||||
stdout=getattr(result, "stdout", None) or None,
|
||||
stderr=getattr(result, "stderr", None) or None,
|
||||
)
|
||||
if result.ok:
|
||||
mark_event_session_terminated(event, by_username=user.username)
|
||||
db.commit()
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/{event_db_id}/actions/qwinsta", response_model=AgentCommandResponse)
|
||||
def post_event_qwinsta(
|
||||
event_db_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> AgentCommandResponse:
|
||||
event = db.get(Event, event_db_id)
|
||||
if event is None:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
cmd = execute_qwinsta_via_winrm(db, event, requested_by=user.username)
|
||||
db.commit()
|
||||
return _agent_command_response(cmd)
|
||||
|
||||
|
||||
@router.post("/{event_db_id}/actions/logoff", response_model=AgentCommandResponse)
|
||||
def post_event_logoff(
|
||||
event_db_id: int,
|
||||
body: LogoffActionBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> AgentCommandResponse:
|
||||
event = db.get(Event, event_db_id)
|
||||
if event is None:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
cmd = execute_logoff_via_winrm(
|
||||
db,
|
||||
event,
|
||||
session_id=body.session_id,
|
||||
requested_by=user.username,
|
||||
)
|
||||
db.commit()
|
||||
return _agent_command_response(cmd)
|
||||
|
||||
|
||||
@router.get("/{event_db_id}/actions/{command_uuid}", response_model=AgentCommandResponse)
|
||||
def get_event_action_status(
|
||||
event_db_id: int,
|
||||
command_uuid: str,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(get_current_user),
|
||||
) -> AgentCommandResponse:
|
||||
cmd = get_command_by_uuid(db, command_uuid)
|
||||
if cmd is None or cmd.event_id != event_db_id:
|
||||
raise HTTPException(status_code=404, detail="Command not found")
|
||||
return _agent_command_response(cmd)
|
||||
|
||||
|
||||
@router.get("/{event_db_id}", response_model=EventDetail)
|
||||
def get_event(
|
||||
event_db_id: int,
|
||||
@@ -179,7 +379,7 @@ def get_event(
|
||||
)
|
||||
if event is None:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
base = event_to_summary(event)
|
||||
base = event_to_summary(event, db)
|
||||
return EventDetail(
|
||||
**base.model_dump(),
|
||||
details=event.details,
|
||||
|
||||
+770
-12
@@ -1,15 +1,74 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.auth.jwt_auth import get_current_user, require_admin
|
||||
from app.config import get_settings
|
||||
from app.database import get_db
|
||||
from app.models import Event, Host
|
||||
from app.schemas.list_models import HostDetail, HostListResponse, HostSummary
|
||||
from app.services.agent_version import latest_agent_versions_by_product
|
||||
from app.utils.sql_like import escape_ilike_pattern
|
||||
from app.services.agent_version import (
|
||||
latest_agent_versions_by_product,
|
||||
reference_agent_versions_by_product,
|
||||
)
|
||||
from app.services.agent_git_release import get_git_release_versions
|
||||
from app.services.agent_update import (
|
||||
AgentUpdateNotAllowedError,
|
||||
host_version_status,
|
||||
request_agent_update,
|
||||
)
|
||||
from app.services.host_remote_actions import (
|
||||
RemoteActionAlreadyRunningError,
|
||||
cancel_host_remote_action,
|
||||
clear_stale_running_remote_actions,
|
||||
get_remote_action_status,
|
||||
run_agent_fallback_action,
|
||||
run_ssh_monitor_update_action,
|
||||
start_host_remote_action,
|
||||
)
|
||||
from app.services.agent_update_settings import (
|
||||
PRODUCT_RDP,
|
||||
PRODUCT_SSH,
|
||||
get_effective_agent_update_config,
|
||||
)
|
||||
from app.services.agent_host_config import get_host_agent_settings, update_host_agent_config
|
||||
from app.services.host_sessions import (
|
||||
HostSessionRow,
|
||||
list_linux_sessions,
|
||||
list_windows_sessions,
|
||||
terminate_linux_session,
|
||||
terminate_windows_session,
|
||||
)
|
||||
from app.services.host_delete import delete_host_and_related
|
||||
from app.services.host_mgmt_credentials import (
|
||||
get_host_mgmt_access_view,
|
||||
upsert_host_mgmt_credentials,
|
||||
)
|
||||
from app.services.linux_admin_settings import get_effective_linux_admin_for_host
|
||||
from app.services.win_admin_settings import get_effective_win_admin_for_host
|
||||
from app.services.winrm_connect import (
|
||||
HostNotWindowsError,
|
||||
HostTargetMissingError,
|
||||
WinAdminNotConfiguredError,
|
||||
WinRmTestResult,
|
||||
iter_winrm_targets,
|
||||
test_winrm_connection,
|
||||
)
|
||||
from app.services.ssh_connect import (
|
||||
HostNotLinuxError as SshHostNotLinuxError,
|
||||
HostTargetMissingError as SshHostTargetMissingError,
|
||||
LinuxAdminNotConfiguredError,
|
||||
SshCommandResult,
|
||||
iter_ssh_targets,
|
||||
run_ssh_monitor_update,
|
||||
test_ssh_connection,
|
||||
)
|
||||
from app.services.host_health import (
|
||||
HEARTBEAT_TYPE,
|
||||
agent_status as compute_agent_status,
|
||||
@@ -17,10 +76,19 @@ from app.services.host_health import (
|
||||
max_daily_report_time_by_host,
|
||||
max_event_time_by_host,
|
||||
)
|
||||
from app.services.event_type_visibility import get_hidden_event_types, visibility_type_filter
|
||||
|
||||
router = APIRouter(prefix="/hosts", tags=["hosts"])
|
||||
|
||||
|
||||
def _count_visible_events(db: Session, host_id: int, hidden: frozenset[str]) -> int:
|
||||
stmt = select(func.count()).select_from(Event).where(Event.host_id == host_id)
|
||||
type_filter = visibility_type_filter(hidden)
|
||||
if type_filter is not None:
|
||||
stmt = stmt.where(type_filter)
|
||||
return int(db.scalar(stmt) or 0)
|
||||
|
||||
|
||||
@router.get("", response_model=HostListResponse)
|
||||
def list_hosts(
|
||||
page: int = Query(1, ge=1),
|
||||
@@ -38,8 +106,8 @@ def list_hosts(
|
||||
stmt = stmt.where(Host.product == product)
|
||||
count_stmt = count_stmt.where(Host.product == product)
|
||||
if hostname:
|
||||
like = f"%{hostname}%"
|
||||
host_match = Host.hostname.ilike(like) | Host.display_name.ilike(like)
|
||||
like = f"%{escape_ilike_pattern(hostname)}%"
|
||||
host_match = Host.hostname.ilike(like, escape="\\") | Host.display_name.ilike(like, escape="\\")
|
||||
stmt = stmt.where(host_match)
|
||||
count_stmt = count_stmt.where(host_match)
|
||||
|
||||
@@ -61,12 +129,19 @@ def list_hosts(
|
||||
|
||||
hb_map = max_event_time_by_host(db, HEARTBEAT_TYPE)
|
||||
report_map = max_daily_report_time_by_host(db)
|
||||
latest_map = latest_agent_versions_by_product(db)
|
||||
update_cfg = get_effective_agent_update_config(db)
|
||||
git_release = get_git_release_versions(update_cfg, db=db)
|
||||
reference_map = reference_agent_versions_by_product(
|
||||
update_cfg,
|
||||
latest_map,
|
||||
git_versions=git_release.versions,
|
||||
)
|
||||
hidden = get_hidden_event_types(db)
|
||||
|
||||
items: list[HostSummary] = []
|
||||
for host in rows:
|
||||
event_count = db.scalar(
|
||||
select(func.count()).select_from(Event).where(Event.host_id == host.id)
|
||||
)
|
||||
event_count = _count_visible_events(db, host.id, hidden)
|
||||
last_hb = hb_map.get(host.id)
|
||||
items.append(
|
||||
HostSummary(
|
||||
@@ -86,6 +161,13 @@ def list_hosts(
|
||||
agent_status=compute_agent_status(
|
||||
last_hb, stale_minutes=settings.sac_heartbeat_stale_minutes
|
||||
),
|
||||
version_status=host_version_status(
|
||||
host,
|
||||
cfg=update_cfg,
|
||||
latest_map=latest_map,
|
||||
git_versions=git_release.versions,
|
||||
),
|
||||
pending_agent_update=bool(host.pending_agent_update),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -94,10 +176,18 @@ def list_hosts(
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
latest_agent_versions=latest_agent_versions_by_product(db),
|
||||
latest_agent_versions=latest_map,
|
||||
reference_agent_versions=reference_map,
|
||||
git_latest_rdp_version=git_release.versions.get(PRODUCT_RDP) or None,
|
||||
git_latest_ssh_version=git_release.versions.get(PRODUCT_SSH) or None,
|
||||
)
|
||||
|
||||
|
||||
class HostManualAddBody(BaseModel):
|
||||
platform: Literal["windows", "linux"]
|
||||
target: str = Field(..., min_length=1, max_length=253)
|
||||
|
||||
|
||||
def _host_detail_from_model(
|
||||
host: Host,
|
||||
*,
|
||||
@@ -106,10 +196,12 @@ def _host_detail_from_model(
|
||||
) -> HostDetail:
|
||||
hb_map = max_event_time_by_host(db, HEARTBEAT_TYPE)
|
||||
report_map = max_daily_report_time_by_host(db)
|
||||
event_count = db.scalar(
|
||||
select(func.count()).select_from(Event).where(Event.host_id == host.id)
|
||||
)
|
||||
hidden = get_hidden_event_types(db)
|
||||
event_count = _count_visible_events(db, host.id, hidden)
|
||||
last_hb = hb_map.get(host.id)
|
||||
latest_map = latest_agent_versions_by_product(db)
|
||||
update_cfg = get_effective_agent_update_config(db)
|
||||
git_release = get_git_release_versions(update_cfg, db=db)
|
||||
return HostDetail(
|
||||
id=host.id,
|
||||
hostname=host.hostname,
|
||||
@@ -126,15 +218,36 @@ def _host_detail_from_model(
|
||||
last_daily_report_at=report_map.get(host.id),
|
||||
last_inventory_at=host.inventory_updated_at,
|
||||
agent_status=compute_agent_status(last_hb, stale_minutes=settings.sac_heartbeat_stale_minutes),
|
||||
version_status=host_version_status(
|
||||
host,
|
||||
cfg=update_cfg,
|
||||
latest_map=latest_map,
|
||||
git_versions=git_release.versions,
|
||||
),
|
||||
pending_agent_update=bool(host.pending_agent_update),
|
||||
agent_instance_id=host.agent_instance_id,
|
||||
tags=host.tags if isinstance(host.tags, list) else [],
|
||||
use_sac_mode=host.use_sac_mode,
|
||||
inventory=host.inventory if isinstance(host.inventory, dict) else None,
|
||||
inventory_updated_at=host.inventory_updated_at,
|
||||
created_at=host.created_at,
|
||||
ssh_admin_ok=host.ssh_admin_ok,
|
||||
ssh_admin_checked_at=host.ssh_admin_checked_at,
|
||||
pending_update_requested_at=host.pending_update_requested_at,
|
||||
pending_update_target_version=host.pending_update_target_version,
|
||||
agent_config_revision=int(host.agent_config_revision or 0),
|
||||
agent_config=get_host_agent_settings(host) or None,
|
||||
agent_update_state=host.agent_update_state,
|
||||
agent_update_last_at=host.agent_update_last_at,
|
||||
agent_update_last_error=host.agent_update_last_error,
|
||||
)
|
||||
|
||||
|
||||
def _set_ssh_admin_status(host: Host, ok: bool) -> None:
|
||||
host.ssh_admin_ok = ok
|
||||
host.ssh_admin_checked_at = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
@router.get("/{host_id}", response_model=HostDetail)
|
||||
def get_host(
|
||||
host_id: int,
|
||||
@@ -156,6 +269,651 @@ class HostDeleteResponse(BaseModel):
|
||||
deleted_problems: int
|
||||
|
||||
|
||||
class HostWinRmTestResponse(BaseModel):
|
||||
ok: bool
|
||||
message: str
|
||||
target: str
|
||||
hostname: str | None = None
|
||||
|
||||
|
||||
class HostSshActionResponse(BaseModel):
|
||||
ok: bool
|
||||
message: str
|
||||
target: str
|
||||
stdout: str | None = None
|
||||
stderr: str | None = None
|
||||
exit_code: int | None = None
|
||||
product_version: str | None = None
|
||||
ssh_admin_ok: bool | None = None
|
||||
|
||||
|
||||
class HostAgentUpdateRequestResponse(BaseModel):
|
||||
status: str
|
||||
pending_agent_update: bool
|
||||
target_version: str | None = None
|
||||
agent_update_state: str | None = None
|
||||
|
||||
|
||||
class HostAgentConfigUpdate(BaseModel):
|
||||
settings: dict[str, object] = {}
|
||||
|
||||
|
||||
class HostAgentConfigResponse(BaseModel):
|
||||
config_revision: int
|
||||
settings: dict[str, object]
|
||||
|
||||
|
||||
class HostMgmtAccessResponse(BaseModel):
|
||||
host_id: int
|
||||
has_override: bool
|
||||
user: str | None = None
|
||||
password_set: bool = False
|
||||
password_hint: str | None = None
|
||||
effective_source: str
|
||||
effective_configured: bool
|
||||
effective_user: str | None = None
|
||||
|
||||
|
||||
class HostMgmtAccessUpdate(BaseModel):
|
||||
user: str | None = None
|
||||
password: str | None = None
|
||||
clear: bool = False
|
||||
|
||||
|
||||
class HostRemoteActionStartResponse(BaseModel):
|
||||
status: str
|
||||
host_id: int
|
||||
title: str
|
||||
message: str
|
||||
|
||||
|
||||
@router.post(
|
||||
"/manual-add",
|
||||
response_model=HostRemoteActionStartResponse,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
def post_host_manual_add(
|
||||
body: HostManualAddBody,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> HostRemoteActionStartResponse:
|
||||
from app.services.host_manual_add import ManualHostAddError, prepare_manual_host_add
|
||||
|
||||
try:
|
||||
host = prepare_manual_host_add(db, platform=body.platform, target=body.target)
|
||||
except ManualHostAddError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
if body.platform == "windows":
|
||||
title = f"Ручное добавление Windows: {host.hostname}"
|
||||
else:
|
||||
title = f"Ручное добавление Linux: {host.hostname}"
|
||||
|
||||
try:
|
||||
start_host_remote_action(
|
||||
db,
|
||||
host,
|
||||
title=title,
|
||||
runner=run_agent_fallback_action,
|
||||
)
|
||||
except RemoteActionAlreadyRunningError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
|
||||
return HostRemoteActionStartResponse(
|
||||
status="running",
|
||||
host_id=host.id,
|
||||
title=title,
|
||||
message="Проверка пройдена, установка агента запущена на сервере SAC",
|
||||
)
|
||||
|
||||
|
||||
class HostRemoteActionJobResponse(BaseModel):
|
||||
active: bool
|
||||
host_id: int
|
||||
hostname: str | None = None
|
||||
status: str | None = None
|
||||
title: str = ""
|
||||
message: str = ""
|
||||
stdout: str | None = None
|
||||
stderr: str | None = None
|
||||
output: str | None = None
|
||||
ok: bool | None = None
|
||||
exit_code: int | None = None
|
||||
product_version: str | None = None
|
||||
target: str | None = None
|
||||
agent_update_state: str | None = None
|
||||
started_at: str | None = None
|
||||
finished_at: str | None = None
|
||||
|
||||
|
||||
class HostSessionItem(BaseModel):
|
||||
session_id: str
|
||||
user: str
|
||||
tty: str | None = None
|
||||
state: str | None = None
|
||||
source_ip: str | None = None
|
||||
session_name: str | None = None
|
||||
|
||||
|
||||
class HostSessionsResponse(BaseModel):
|
||||
ok: bool
|
||||
message: str
|
||||
target: str | None = None
|
||||
sessions: list[HostSessionItem]
|
||||
|
||||
|
||||
class HostSessionTerminateBody(BaseModel):
|
||||
session_id: str
|
||||
|
||||
|
||||
class HostSessionTerminateResponse(BaseModel):
|
||||
ok: bool
|
||||
message: str
|
||||
target: str | None = None
|
||||
stdout: str | None = None
|
||||
stderr: str | None = None
|
||||
|
||||
|
||||
def _session_items(rows: list[HostSessionRow]) -> list[HostSessionItem]:
|
||||
return [
|
||||
HostSessionItem(
|
||||
session_id=r.session_id,
|
||||
user=r.user,
|
||||
tty=r.tty,
|
||||
state=r.state,
|
||||
source_ip=r.source_ip,
|
||||
session_name=r.session_name,
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def _ssh_action_response(
|
||||
result: SshCommandResult,
|
||||
*,
|
||||
attempts: list[str] | None = None,
|
||||
ssh_admin_ok: bool | None = None,
|
||||
) -> HostSshActionResponse:
|
||||
message = result.message
|
||||
if attempts:
|
||||
message = f"{message} (пробовали: {', '.join(attempts)})"
|
||||
return HostSshActionResponse(
|
||||
ok=result.ok,
|
||||
message=message,
|
||||
target=result.target,
|
||||
stdout=result.stdout or None,
|
||||
stderr=result.stderr or None,
|
||||
exit_code=result.exit_code,
|
||||
product_version=result.agent_version,
|
||||
ssh_admin_ok=ssh_admin_ok,
|
||||
)
|
||||
|
||||
|
||||
def _run_ssh_action_on_host(
|
||||
host: Host,
|
||||
cfg,
|
||||
*,
|
||||
action,
|
||||
) -> HostSshActionResponse:
|
||||
try:
|
||||
targets = iter_ssh_targets(host)
|
||||
except SshHostNotLinuxError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except SshHostTargetMissingError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
last_result: SshCommandResult | None = None
|
||||
attempts: list[str] = []
|
||||
for target in targets:
|
||||
try:
|
||||
result = action(target=target, user=cfg.user, password=cfg.password)
|
||||
except LinuxAdminNotConfiguredError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
last_result = result
|
||||
attempts.append(f"{target}={'OK' if result.ok else 'fail'}")
|
||||
if result.ok:
|
||||
return _ssh_action_response(result, attempts=attempts if len(attempts) > 1 else None)
|
||||
|
||||
assert last_result is not None
|
||||
return _ssh_action_response(last_result, attempts=attempts if len(attempts) > 1 else None)
|
||||
|
||||
|
||||
@router.post("/{host_id}/actions/winrm-test", response_model=HostWinRmTestResponse)
|
||||
def test_host_winrm(
|
||||
host_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> HostWinRmTestResponse:
|
||||
host = db.get(Host, host_id)
|
||||
if host is None:
|
||||
raise HTTPException(status_code=404, detail="Host not found")
|
||||
|
||||
cfg = get_effective_win_admin_for_host(db, host)
|
||||
if not cfg.configured:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Windows admin is not configured (host override or Settings → Windows)",
|
||||
)
|
||||
|
||||
try:
|
||||
targets = iter_winrm_targets(host)
|
||||
except HostNotWindowsError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except HostTargetMissingError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
last_result = None
|
||||
attempts: list[str] = []
|
||||
for target in targets:
|
||||
try:
|
||||
result = test_winrm_connection(
|
||||
target=target,
|
||||
user=cfg.user,
|
||||
password=cfg.password,
|
||||
)
|
||||
except WinAdminNotConfiguredError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
last_result = result
|
||||
attempts.append(f"{target}={'OK' if result.ok else 'fail'}")
|
||||
if result.ok:
|
||||
if len(targets) > 1:
|
||||
result = WinRmTestResult(
|
||||
ok=True,
|
||||
message=f"{result.message} (пробовали: {', '.join(attempts)})",
|
||||
target=result.target,
|
||||
hostname=result.hostname,
|
||||
)
|
||||
return HostWinRmTestResponse(
|
||||
ok=result.ok,
|
||||
message=result.message,
|
||||
target=result.target,
|
||||
hostname=result.hostname,
|
||||
)
|
||||
|
||||
assert last_result is not None
|
||||
message = last_result.message
|
||||
if len(attempts) > 1:
|
||||
message = f"{message} (пробовали: {', '.join(attempts)})"
|
||||
return HostWinRmTestResponse(
|
||||
ok=False,
|
||||
message=message,
|
||||
target=last_result.target,
|
||||
hostname=last_result.hostname,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{host_id}/actions/ssh-test", response_model=HostSshActionResponse)
|
||||
def test_host_ssh(
|
||||
host_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> HostSshActionResponse:
|
||||
host = db.get(Host, host_id)
|
||||
if host is None:
|
||||
raise HTTPException(status_code=404, detail="Host not found")
|
||||
|
||||
cfg = get_effective_linux_admin_for_host(db, host)
|
||||
if not cfg.configured:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Linux SSH admin is not configured (host override or Settings → Linux)",
|
||||
)
|
||||
|
||||
response = _run_ssh_action_on_host(host, cfg, action=test_ssh_connection)
|
||||
_set_ssh_admin_status(host, response.ok)
|
||||
db.commit()
|
||||
return response.model_copy(update={"ssh_admin_ok": host.ssh_admin_ok})
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{host_id}/actions/agent-update",
|
||||
response_model=HostRemoteActionStartResponse,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
def update_host_agent_via_ssh(
|
||||
host_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> HostRemoteActionStartResponse:
|
||||
host = db.get(Host, host_id)
|
||||
if host is None:
|
||||
raise HTTPException(status_code=404, detail="Host not found")
|
||||
|
||||
cfg = get_effective_linux_admin_for_host(db, host)
|
||||
if not cfg.configured:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Linux SSH admin is not configured (host override or Settings → Linux)",
|
||||
)
|
||||
|
||||
host_label = (host.display_name or host.hostname or "").strip() or f"host#{host.id}"
|
||||
title = f"Обновление ssh-monitor (SSH): {host_label}"
|
||||
try:
|
||||
start_host_remote_action(
|
||||
db,
|
||||
host,
|
||||
title=title,
|
||||
runner=run_ssh_monitor_update_action,
|
||||
poll_remote_log=True,
|
||||
)
|
||||
except RemoteActionAlreadyRunningError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return HostRemoteActionStartResponse(
|
||||
status="running",
|
||||
host_id=host.id,
|
||||
title=title,
|
||||
message="Обновление запущено на сервере SAC и продолжится в фоне",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{host_id}/actions/request-agent-update", response_model=HostAgentUpdateRequestResponse)
|
||||
def post_request_agent_update(
|
||||
host_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> HostAgentUpdateRequestResponse:
|
||||
host = db.get(Host, host_id)
|
||||
if host is None:
|
||||
raise HTTPException(status_code=404, detail="Host not found")
|
||||
try:
|
||||
request_agent_update(db, host)
|
||||
except AgentUpdateNotAllowedError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
db.commit()
|
||||
return HostAgentUpdateRequestResponse(
|
||||
status="requested",
|
||||
pending_agent_update=host.pending_agent_update,
|
||||
target_version=host.pending_update_target_version,
|
||||
agent_update_state=host.agent_update_state,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{host_id}/actions/agent-update-fallback",
|
||||
response_model=HostRemoteActionStartResponse,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
def post_agent_update_fallback(
|
||||
host_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> HostRemoteActionStartResponse:
|
||||
host = db.get(Host, host_id)
|
||||
if host is None:
|
||||
raise HTTPException(status_code=404, detail="Host not found")
|
||||
|
||||
from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH
|
||||
from app.services.ssh_connect import is_linux_host
|
||||
from app.services.winrm_connect import is_windows_host
|
||||
|
||||
product = (host.product or "").strip()
|
||||
host_label = (host.display_name or host.hostname or "").strip() or f"host#{host.id}"
|
||||
if product == PRODUCT_RDP or is_windows_host(host):
|
||||
title = f"Обновление {host_label} через WinRM"
|
||||
elif product == PRODUCT_SSH or is_linux_host(host):
|
||||
title = f"Fallback SSH (ssh-monitor): {host_label}"
|
||||
else:
|
||||
title = f"Обновление агента (fallback): {host_label}"
|
||||
|
||||
try:
|
||||
start_host_remote_action(
|
||||
db,
|
||||
host,
|
||||
title=title,
|
||||
runner=run_agent_fallback_action,
|
||||
)
|
||||
except RemoteActionAlreadyRunningError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return HostRemoteActionStartResponse(
|
||||
status="running",
|
||||
host_id=host.id,
|
||||
title=title,
|
||||
message="Обновление запущено на сервере SAC и продолжится в фоне",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{host_id}/actions/remote-job", response_model=HostRemoteActionJobResponse)
|
||||
def get_host_remote_job(
|
||||
host_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> HostRemoteActionJobResponse:
|
||||
host = db.get(Host, host_id)
|
||||
if host is None:
|
||||
raise HTTPException(status_code=404, detail="Host not found")
|
||||
db.refresh(host)
|
||||
return HostRemoteActionJobResponse(**get_remote_action_status(host))
|
||||
|
||||
|
||||
@router.post("/{host_id}/actions/remote-job/cancel", response_model=HostRemoteActionJobResponse)
|
||||
def cancel_host_remote_job(
|
||||
host_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> HostRemoteActionJobResponse:
|
||||
host = db.get(Host, host_id)
|
||||
if host is None:
|
||||
raise HTTPException(status_code=404, detail="Host not found")
|
||||
if not cancel_host_remote_action(db, host):
|
||||
raise HTTPException(status_code=409, detail="No running remote action for this host")
|
||||
db.refresh(host)
|
||||
return HostRemoteActionJobResponse(**get_remote_action_status(host))
|
||||
|
||||
|
||||
@router.post("/{host_id}/actions/sessions/list", response_model=HostSessionsResponse)
|
||||
def list_host_sessions(
|
||||
host_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> HostSessionsResponse:
|
||||
host = db.get(Host, host_id)
|
||||
if host is None:
|
||||
raise HTTPException(status_code=404, detail="Host not found")
|
||||
|
||||
from app.services.ssh_connect import is_linux_host
|
||||
from app.services.winrm_connect import is_windows_host
|
||||
|
||||
if is_linux_host(host):
|
||||
cfg = get_effective_linux_admin_for_host(db, host)
|
||||
if not cfg.configured:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Linux SSH admin is not configured (host override or Settings → Linux)",
|
||||
)
|
||||
try:
|
||||
sessions, result = list_linux_sessions(host, cfg)
|
||||
except SshHostNotLinuxError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except SshHostTargetMissingError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return HostSessionsResponse(
|
||||
ok=result.ok,
|
||||
message=result.message,
|
||||
target=result.target or None,
|
||||
sessions=_session_items(sessions),
|
||||
)
|
||||
|
||||
if is_windows_host(host):
|
||||
cfg = get_effective_win_admin_for_host(db, host)
|
||||
if not cfg.configured:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Windows admin is not configured (host override or Settings → Windows)",
|
||||
)
|
||||
try:
|
||||
sessions, result = list_windows_sessions(host, cfg)
|
||||
except HostNotWindowsError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except HostTargetMissingError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
if result is None:
|
||||
raise HTTPException(status_code=502, detail="WinRM qwinsta failed")
|
||||
return HostSessionsResponse(
|
||||
ok=result.ok,
|
||||
message=result.message,
|
||||
target=result.target or None,
|
||||
sessions=_session_items(sessions),
|
||||
)
|
||||
|
||||
raise HTTPException(status_code=400, detail="Host OS does not support live sessions")
|
||||
|
||||
|
||||
@router.post("/{host_id}/actions/sessions/terminate", response_model=HostSessionTerminateResponse)
|
||||
def terminate_host_session(
|
||||
host_id: int,
|
||||
body: HostSessionTerminateBody,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> HostSessionTerminateResponse:
|
||||
host = db.get(Host, host_id)
|
||||
if host is None:
|
||||
raise HTTPException(status_code=404, detail="Host not found")
|
||||
|
||||
from app.services.ssh_connect import is_linux_host
|
||||
from app.services.winrm_connect import is_windows_host
|
||||
|
||||
sid = body.session_id.strip()
|
||||
if not sid:
|
||||
raise HTTPException(status_code=422, detail="session_id is required")
|
||||
|
||||
if is_linux_host(host):
|
||||
cfg = get_effective_linux_admin_for_host(db, host)
|
||||
if not cfg.configured:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Linux SSH admin is not configured (host override or Settings → Linux)",
|
||||
)
|
||||
try:
|
||||
result = terminate_linux_session(host, cfg, sid)
|
||||
except SshHostNotLinuxError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except SshHostTargetMissingError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return HostSessionTerminateResponse(
|
||||
ok=result.ok,
|
||||
message=result.message,
|
||||
target=result.target or None,
|
||||
stdout=result.stdout or None,
|
||||
stderr=result.stderr or None,
|
||||
)
|
||||
|
||||
if is_windows_host(host):
|
||||
cfg = get_effective_win_admin_for_host(db, host)
|
||||
if not cfg.configured:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Windows admin is not configured (host override or Settings → Windows)",
|
||||
)
|
||||
try:
|
||||
result = terminate_windows_session(host, cfg, sid)
|
||||
except HostNotWindowsError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
if result is None:
|
||||
raise HTTPException(status_code=502, detail="WinRM logoff failed")
|
||||
return HostSessionTerminateResponse(
|
||||
ok=result.ok,
|
||||
message=result.message,
|
||||
target=result.target or None,
|
||||
stdout=result.stdout or None,
|
||||
stderr=result.stderr or None,
|
||||
)
|
||||
|
||||
raise HTTPException(status_code=400, detail="Host OS does not support session terminate")
|
||||
|
||||
|
||||
@router.patch("/{host_id}/agent-config", response_model=HostAgentConfigResponse)
|
||||
def patch_host_agent_config(
|
||||
host_id: int,
|
||||
body: HostAgentConfigUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> HostAgentConfigResponse:
|
||||
host = db.get(Host, host_id)
|
||||
if host is None:
|
||||
raise HTTPException(status_code=404, detail="Host not found")
|
||||
try:
|
||||
update_host_agent_config(db, host, body.settings)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
db.commit()
|
||||
settings = get_host_agent_settings(host)
|
||||
return HostAgentConfigResponse(
|
||||
config_revision=int(host.agent_config_revision or 0),
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{host_id}/access", response_model=HostMgmtAccessResponse)
|
||||
def get_host_access(
|
||||
host_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> HostMgmtAccessResponse:
|
||||
host = db.get(Host, host_id)
|
||||
if host is None:
|
||||
raise HTTPException(status_code=404, detail="Host not found")
|
||||
view = get_host_mgmt_access_view(db, host)
|
||||
return HostMgmtAccessResponse(
|
||||
host_id=view.host_id,
|
||||
has_override=view.has_override,
|
||||
user=view.user,
|
||||
password_set=view.password_set,
|
||||
password_hint=view.password_hint,
|
||||
effective_source=view.effective_source,
|
||||
effective_configured=view.effective_configured,
|
||||
effective_user=view.effective_user,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{host_id}/access", response_model=HostMgmtAccessResponse)
|
||||
def put_host_access(
|
||||
host_id: int,
|
||||
body: HostMgmtAccessUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> HostMgmtAccessResponse:
|
||||
host = db.get(Host, host_id)
|
||||
if host is None:
|
||||
raise HTTPException(status_code=404, detail="Host not found")
|
||||
view = upsert_host_mgmt_credentials(
|
||||
db,
|
||||
host,
|
||||
user=body.user,
|
||||
password=body.password,
|
||||
clear=body.clear,
|
||||
)
|
||||
return HostMgmtAccessResponse(
|
||||
host_id=view.host_id,
|
||||
has_override=view.has_override,
|
||||
user=view.user,
|
||||
password_set=view.password_set,
|
||||
password_hint=view.password_hint,
|
||||
effective_source=view.effective_source,
|
||||
effective_configured=view.effective_configured,
|
||||
effective_user=view.effective_user,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{host_id}/agent-config", response_model=HostAgentConfigResponse)
|
||||
def get_host_agent_config(
|
||||
host_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> HostAgentConfigResponse:
|
||||
host = db.get(Host, host_id)
|
||||
if host is None:
|
||||
raise HTTPException(status_code=404, detail="Host not found")
|
||||
settings = get_host_agent_settings(host)
|
||||
return HostAgentConfigResponse(
|
||||
config_revision=int(host.agent_config_revision or 0),
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{host_id}", response_model=HostDeleteResponse)
|
||||
def delete_host(
|
||||
host_id: int,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import datetime
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -13,6 +13,7 @@ from app.services.mobile_devices import (
|
||||
get_device_or_raise,
|
||||
list_mobile_devices,
|
||||
revoke_device,
|
||||
delete_device_record,
|
||||
touch_device,
|
||||
update_fcm_token,
|
||||
assert_device_active,
|
||||
@@ -257,8 +258,8 @@ def get_devices(
|
||||
return [_device_to_response(item) for item in list_mobile_devices(db)]
|
||||
|
||||
|
||||
@router.delete("/devices/{device_id}", response_model=MobileDeviceResponse)
|
||||
def delete_device(
|
||||
@router.post("/devices/{device_id}/revoke", response_model=MobileDeviceResponse)
|
||||
def post_revoke_device(
|
||||
device_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
@@ -270,6 +271,18 @@ def delete_device(
|
||||
return _device_to_response(summary)
|
||||
|
||||
|
||||
@router.delete("/devices/{device_id}", status_code=204)
|
||||
def delete_device(
|
||||
device_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> None:
|
||||
try:
|
||||
delete_device_record(db, device_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/devices/{device_id}/test-push", response_model=TestPushResponse)
|
||||
def test_device_push(
|
||||
device_id: int,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ from app.auth.jwt_auth import get_current_user
|
||||
from app.database import get_db
|
||||
|
||||
from app.models import Event, Host, Problem, ProblemEvent
|
||||
from app.services.event_type_visibility import get_hidden_event_types
|
||||
from app.utils.sql_like import escape_ilike_pattern
|
||||
|
||||
|
||||
|
||||
@@ -192,11 +194,11 @@ def list_problems(
|
||||
|
||||
if hostname:
|
||||
|
||||
like = f"%{hostname}%"
|
||||
like = f"%{escape_ilike_pattern(hostname)}%"
|
||||
|
||||
stmt = stmt.where(Host.hostname.ilike(like) | Host.display_name.ilike(like))
|
||||
stmt = stmt.where(Host.hostname.ilike(like, escape="\\") | Host.display_name.ilike(like, escape="\\"))
|
||||
|
||||
count_stmt = count_stmt.where(Host.hostname.ilike(like) | Host.display_name.ilike(like))
|
||||
count_stmt = count_stmt.where(Host.hostname.ilike(like, escape="\\") | Host.display_name.ilike(like, escape="\\"))
|
||||
|
||||
if created_within_hours is not None:
|
||||
|
||||
@@ -269,6 +271,8 @@ def get_problem(
|
||||
.order_by(Event.occurred_at.desc())
|
||||
|
||||
).all()
|
||||
hidden = get_hidden_event_types(db)
|
||||
event_rows = [event for event in event_rows if event.type not in hidden]
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1 import auth, dashboards, events, health, hosts, mobile, problems, settings, stream, system, users
|
||||
from app.api.v1 import agent, auth, dashboards, events, health, hosts, mobile, problems, settings, stream, system, users
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(health.router)
|
||||
api_router.include_router(auth.router)
|
||||
api_router.include_router(agent.router)
|
||||
api_router.include_router(events.router)
|
||||
api_router.include_router(hosts.router)
|
||||
api_router.include_router(problems.router)
|
||||
|
||||
+436
-17
@@ -1,3 +1,7 @@
|
||||
from dataclasses import replace
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -33,10 +37,35 @@ from app.services.event_severity_overrides import (
|
||||
list_severity_override_items,
|
||||
replace_severity_overrides,
|
||||
)
|
||||
from app.services.event_type_visibility import replace_event_visibility
|
||||
from app.services.ui_settings import (
|
||||
get_effective_ui_settings,
|
||||
upsert_ui_settings,
|
||||
)
|
||||
from app.services.linux_admin_settings import (
|
||||
get_effective_linux_admin_config,
|
||||
upsert_linux_admin_settings,
|
||||
)
|
||||
from app.services.win_admin_settings import (
|
||||
get_effective_win_admin_config,
|
||||
upsert_win_admin_settings,
|
||||
)
|
||||
from app.services.rdp_flap_settings import (
|
||||
get_effective_rdp_flap_settings,
|
||||
upsert_rdp_flap_settings,
|
||||
)
|
||||
from app.services.agent_git_release import get_git_release_versions
|
||||
from app.services.agent_update_settings import (
|
||||
get_effective_agent_update_config,
|
||||
upsert_agent_update_settings,
|
||||
)
|
||||
from app.services.winrm_connect import (
|
||||
HostNotWindowsError,
|
||||
HostTargetMissingError,
|
||||
WinAdminNotConfiguredError,
|
||||
resolve_windows_host_target,
|
||||
test_winrm_connection,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
|
||||
@@ -351,6 +380,7 @@ class EventSeverityOverrideRow(BaseModel):
|
||||
event_type: str
|
||||
default_severity: str
|
||||
override_severity: str | None = None
|
||||
show_in_events: bool = True
|
||||
|
||||
|
||||
class EventSeverityOverridesResponse(BaseModel):
|
||||
@@ -360,8 +390,25 @@ class EventSeverityOverridesResponse(BaseModel):
|
||||
|
||||
class EventSeverityOverridesUpdate(BaseModel):
|
||||
overrides: dict[str, str | None] = Field(
|
||||
default_factory=dict,
|
||||
description="event_type → severity; null или пустая строка сбрасывает override",
|
||||
)
|
||||
visibility: dict[str, bool | None] = Field(
|
||||
default_factory=dict,
|
||||
description="event_type → show_in_events; null или true сбрасывает скрытие (показывать)",
|
||||
)
|
||||
|
||||
|
||||
def _severity_override_rows(items) -> list[EventSeverityOverrideRow]:
|
||||
return [
|
||||
EventSeverityOverrideRow(
|
||||
event_type=i.event_type,
|
||||
default_severity=i.default_severity,
|
||||
override_severity=i.override_severity,
|
||||
show_in_events=i.show_in_events,
|
||||
)
|
||||
for i in items
|
||||
]
|
||||
|
||||
|
||||
@router.get("/notifications/severity-overrides", response_model=EventSeverityOverridesResponse)
|
||||
@@ -371,14 +418,7 @@ def get_severity_overrides(
|
||||
) -> EventSeverityOverridesResponse:
|
||||
items = list_severity_override_items(db)
|
||||
return EventSeverityOverridesResponse(
|
||||
items=[
|
||||
EventSeverityOverrideRow(
|
||||
event_type=i.event_type,
|
||||
default_severity=i.default_severity,
|
||||
override_severity=i.override_severity,
|
||||
)
|
||||
for i in items
|
||||
],
|
||||
items=_severity_override_rows(items),
|
||||
source=SOURCE_DB,
|
||||
)
|
||||
|
||||
@@ -390,19 +430,19 @@ def update_severity_overrides(
|
||||
_user=Depends(require_admin),
|
||||
) -> EventSeverityOverridesResponse:
|
||||
try:
|
||||
items = replace_severity_overrides(db, body.overrides)
|
||||
items = list_severity_override_items(db)
|
||||
if body.overrides:
|
||||
items = replace_severity_overrides(db, body.overrides)
|
||||
if body.visibility:
|
||||
replace_event_visibility(db, body.visibility)
|
||||
items = list_severity_override_items(db)
|
||||
if not body.overrides and not body.visibility:
|
||||
raise HTTPException(status_code=422, detail="Нет изменений для сохранения")
|
||||
db.commit()
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
return EventSeverityOverridesResponse(
|
||||
items=[
|
||||
EventSeverityOverrideRow(
|
||||
event_type=i.event_type,
|
||||
default_severity=i.default_severity,
|
||||
override_severity=i.override_severity,
|
||||
)
|
||||
for i in items
|
||||
],
|
||||
items=_severity_override_rows(items),
|
||||
source=SOURCE_DB,
|
||||
)
|
||||
|
||||
@@ -439,3 +479,382 @@ def update_ui_settings(
|
||||
show_sidebar_system_stats=cfg.show_sidebar_system_stats,
|
||||
source=cfg.source,
|
||||
)
|
||||
|
||||
|
||||
class WinAdminSettingsResponse(BaseModel):
|
||||
configured: bool
|
||||
user: str | None = None
|
||||
password_hint: str | None = None
|
||||
source: str = Field(description="env или db")
|
||||
|
||||
|
||||
class WinAdminSettingsUpdate(BaseModel):
|
||||
user: str | None = None
|
||||
password: str | None = None
|
||||
|
||||
|
||||
@router.get("/win-admin", response_model=WinAdminSettingsResponse)
|
||||
def get_win_admin_settings(
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> WinAdminSettingsResponse:
|
||||
cfg = get_effective_win_admin_config(db)
|
||||
return WinAdminSettingsResponse(
|
||||
configured=cfg.configured,
|
||||
user=cfg.user or None,
|
||||
password_hint=_mask_secret(cfg.password),
|
||||
source=cfg.source,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/win-admin", response_model=WinAdminSettingsResponse)
|
||||
def update_win_admin_settings(
|
||||
body: WinAdminSettingsUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> WinAdminSettingsResponse:
|
||||
if body.user is not None and not body.user.strip():
|
||||
raise HTTPException(status_code=422, detail="user must not be empty")
|
||||
cfg = upsert_win_admin_settings(db, user=body.user, password=body.password)
|
||||
return WinAdminSettingsResponse(
|
||||
configured=cfg.configured,
|
||||
user=cfg.user or None,
|
||||
password_hint=_mask_secret(cfg.password),
|
||||
source=cfg.source,
|
||||
)
|
||||
|
||||
|
||||
class RdpFlapSettingsResponse(BaseModel):
|
||||
auto_disconnect: bool
|
||||
win_admin_configured: bool
|
||||
source: str = Field(description="db или default")
|
||||
|
||||
|
||||
class RdpFlapSettingsUpdate(BaseModel):
|
||||
auto_disconnect: bool
|
||||
|
||||
|
||||
@router.get("/rdp-flap", response_model=RdpFlapSettingsResponse)
|
||||
def get_rdp_flap_settings(
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> RdpFlapSettingsResponse:
|
||||
cfg = get_effective_rdp_flap_settings(db)
|
||||
win_cfg = get_effective_win_admin_config(db)
|
||||
return RdpFlapSettingsResponse(
|
||||
auto_disconnect=cfg.auto_disconnect,
|
||||
win_admin_configured=win_cfg.configured,
|
||||
source=cfg.source,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/rdp-flap", response_model=RdpFlapSettingsResponse)
|
||||
def update_rdp_flap_settings(
|
||||
body: RdpFlapSettingsUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> RdpFlapSettingsResponse:
|
||||
cfg = upsert_rdp_flap_settings(db, auto_disconnect=body.auto_disconnect)
|
||||
win_cfg = get_effective_win_admin_config(db)
|
||||
return RdpFlapSettingsResponse(
|
||||
auto_disconnect=cfg.auto_disconnect,
|
||||
win_admin_configured=win_cfg.configured,
|
||||
source=cfg.source,
|
||||
)
|
||||
|
||||
|
||||
class LinuxAdminSettingsResponse(BaseModel):
|
||||
configured: bool
|
||||
user: str | None = None
|
||||
password_hint: str | None = None
|
||||
source: str = Field(description="env или db")
|
||||
|
||||
|
||||
class LinuxAdminSettingsUpdate(BaseModel):
|
||||
user: str | None = None
|
||||
password: str | None = None
|
||||
|
||||
|
||||
@router.get("/linux-admin", response_model=LinuxAdminSettingsResponse)
|
||||
def get_linux_admin_settings(
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> LinuxAdminSettingsResponse:
|
||||
cfg = get_effective_linux_admin_config(db)
|
||||
return LinuxAdminSettingsResponse(
|
||||
configured=cfg.configured,
|
||||
user=cfg.user or None,
|
||||
password_hint=_mask_secret(cfg.password),
|
||||
source=cfg.source,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/linux-admin", response_model=LinuxAdminSettingsResponse)
|
||||
def update_linux_admin_settings(
|
||||
body: LinuxAdminSettingsUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> LinuxAdminSettingsResponse:
|
||||
if body.user is not None and not body.user.strip():
|
||||
raise HTTPException(status_code=422, detail="user must not be empty")
|
||||
cfg = upsert_linux_admin_settings(db, user=body.user, password=body.password)
|
||||
return LinuxAdminSettingsResponse(
|
||||
configured=cfg.configured,
|
||||
user=cfg.user or None,
|
||||
password_hint=_mask_secret(cfg.password),
|
||||
source=cfg.source,
|
||||
)
|
||||
|
||||
|
||||
class AgentUpdateSettingsResponse(BaseModel):
|
||||
mode: str
|
||||
fallback_enabled: bool
|
||||
fallback_after_minutes: int
|
||||
recommended_rdp_version: str | None = None
|
||||
recommended_ssh_version: str | None = None
|
||||
min_rdp_version: str | None = None
|
||||
min_ssh_version: str | None = None
|
||||
win_agent_update_script: str | None = None
|
||||
rdp_git_repo_url: str | None = None
|
||||
ssh_git_repo_url: str | None = None
|
||||
git_branch: str = "main"
|
||||
git_latest_rdp_version: str | None = None
|
||||
git_latest_ssh_version: str | None = None
|
||||
git_fetched_at: datetime | None = None
|
||||
git_errors: dict[str, str] = Field(default_factory=dict)
|
||||
source: str = Field(description="env или db")
|
||||
|
||||
|
||||
class AgentUpdateSettingsUpdate(BaseModel):
|
||||
mode: str | None = None
|
||||
fallback_enabled: bool | None = None
|
||||
fallback_after_minutes: int | None = None
|
||||
recommended_rdp_version: str | None = None
|
||||
recommended_ssh_version: str | None = None
|
||||
min_rdp_version: str | None = None
|
||||
min_ssh_version: str | None = None
|
||||
win_agent_update_script: str | None = None
|
||||
rdp_git_repo_url: str | None = None
|
||||
ssh_git_repo_url: str | None = None
|
||||
git_branch: str | None = None
|
||||
|
||||
|
||||
class AgentGitTestRequest(BaseModel):
|
||||
rdp_git_repo_url: str | None = None
|
||||
ssh_git_repo_url: str | None = None
|
||||
git_branch: str | None = None
|
||||
|
||||
|
||||
class AgentGitTestResponse(BaseModel):
|
||||
git_latest_rdp_version: str | None = None
|
||||
git_latest_ssh_version: str | None = None
|
||||
git_fetched_at: datetime | None = None
|
||||
git_errors: dict[str, str] = Field(default_factory=dict)
|
||||
from_cache: bool = False
|
||||
|
||||
|
||||
def _agent_update_to_response(cfg, git_release=None) -> AgentUpdateSettingsResponse:
|
||||
git_latest_rdp = None
|
||||
git_latest_ssh = None
|
||||
git_fetched_at = None
|
||||
git_errors: dict[str, str] = {}
|
||||
if git_release is not None:
|
||||
from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH
|
||||
|
||||
git_latest_rdp = git_release.versions.get(PRODUCT_RDP) or None
|
||||
git_latest_ssh = git_release.versions.get(PRODUCT_SSH) or None
|
||||
git_fetched_at = git_release.fetched_at
|
||||
git_errors = dict(git_release.errors)
|
||||
return AgentUpdateSettingsResponse(
|
||||
mode=cfg.mode,
|
||||
fallback_enabled=cfg.fallback_enabled,
|
||||
fallback_after_minutes=cfg.fallback_after_minutes,
|
||||
recommended_rdp_version=cfg.recommended_rdp_version or None,
|
||||
recommended_ssh_version=cfg.recommended_ssh_version or None,
|
||||
min_rdp_version=cfg.min_rdp_version or None,
|
||||
min_ssh_version=cfg.min_ssh_version or None,
|
||||
win_agent_update_script=cfg.win_agent_update_script or None,
|
||||
rdp_git_repo_url=cfg.rdp_git_repo_url or None,
|
||||
ssh_git_repo_url=cfg.ssh_git_repo_url or None,
|
||||
git_branch=cfg.git_branch or "main",
|
||||
git_latest_rdp_version=git_latest_rdp,
|
||||
git_latest_ssh_version=git_latest_ssh,
|
||||
git_fetched_at=git_fetched_at,
|
||||
git_errors=git_errors,
|
||||
source=cfg.source,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/agent-updates", response_model=AgentUpdateSettingsResponse)
|
||||
def get_agent_update_settings(
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> AgentUpdateSettingsResponse:
|
||||
cfg = get_effective_agent_update_config(db)
|
||||
git_release = get_git_release_versions(cfg, db=db)
|
||||
return _agent_update_to_response(cfg, git_release)
|
||||
|
||||
|
||||
@router.put("/agent-updates", response_model=AgentUpdateSettingsResponse)
|
||||
def update_agent_update_settings(
|
||||
body: AgentUpdateSettingsUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> AgentUpdateSettingsResponse:
|
||||
try:
|
||||
cfg = upsert_agent_update_settings(
|
||||
db,
|
||||
mode=body.mode,
|
||||
fallback_enabled=body.fallback_enabled,
|
||||
fallback_after_minutes=body.fallback_after_minutes,
|
||||
recommended_rdp_version=body.recommended_rdp_version,
|
||||
recommended_ssh_version=body.recommended_ssh_version,
|
||||
min_rdp_version=body.min_rdp_version,
|
||||
min_ssh_version=body.min_ssh_version,
|
||||
win_agent_update_script=body.win_agent_update_script,
|
||||
rdp_git_repo_url=body.rdp_git_repo_url,
|
||||
ssh_git_repo_url=body.ssh_git_repo_url,
|
||||
git_branch=body.git_branch,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
git_release = get_git_release_versions(cfg, db=db, force_refresh=True)
|
||||
return _agent_update_to_response(cfg, git_release)
|
||||
|
||||
|
||||
@router.post("/agent-updates/test-git", response_model=AgentGitTestResponse)
|
||||
def test_agent_git_release(
|
||||
body: AgentGitTestRequest | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> AgentGitTestResponse:
|
||||
cfg = get_effective_agent_update_config(db)
|
||||
if body is not None:
|
||||
cfg = replace(
|
||||
cfg,
|
||||
rdp_git_repo_url=(body.rdp_git_repo_url or cfg.rdp_git_repo_url).strip(),
|
||||
ssh_git_repo_url=(body.ssh_git_repo_url or cfg.ssh_git_repo_url).strip(),
|
||||
git_branch=(body.git_branch or cfg.git_branch or "main").strip() or "main",
|
||||
)
|
||||
git_release = get_git_release_versions(cfg, db=db, force_refresh=True)
|
||||
from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH
|
||||
|
||||
return AgentGitTestResponse(
|
||||
git_latest_rdp_version=git_release.versions.get(PRODUCT_RDP) or None,
|
||||
git_latest_ssh_version=git_release.versions.get(PRODUCT_SSH) or None,
|
||||
git_fetched_at=git_release.fetched_at,
|
||||
git_errors=dict(git_release.errors),
|
||||
from_cache=git_release.from_cache,
|
||||
)
|
||||
|
||||
|
||||
class LoginSecuritySettingsResponse(BaseModel):
|
||||
ip_whitelist: list[str] = Field(default_factory=list)
|
||||
ip_whitelist_text: str = ""
|
||||
sync_fail2ban: bool = False
|
||||
max_failures: int = 3
|
||||
window_minutes: int = 15
|
||||
fail2ban_available: bool = False
|
||||
source: str = "default"
|
||||
|
||||
|
||||
class LoginSecuritySettingsUpdate(BaseModel):
|
||||
ip_whitelist_text: str = ""
|
||||
sync_fail2ban: bool | None = None
|
||||
|
||||
|
||||
class LoginBlockItem(BaseModel):
|
||||
ip_address: str
|
||||
scope: str
|
||||
failure_count: int | None = None
|
||||
usernames: list[str] = Field(default_factory=list)
|
||||
blocked_until: datetime | None = None
|
||||
reason: str
|
||||
|
||||
|
||||
class LoginUnblockRequest(BaseModel):
|
||||
ip_address: str
|
||||
scope: str = Field(default="both", description="web | ssh | both")
|
||||
|
||||
|
||||
class LoginUnblockResponse(BaseModel):
|
||||
messages: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
def _login_security_response(db: Session) -> LoginSecuritySettingsResponse:
|
||||
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
|
||||
from app.services.fail2ban_sync import fail2ban_available
|
||||
from app.services.login_security_settings import get_effective_login_security_config
|
||||
|
||||
cfg = get_effective_login_security_config(db)
|
||||
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
|
||||
text = (row.login_ip_whitelist or "") if row is not None else ""
|
||||
return LoginSecuritySettingsResponse(
|
||||
ip_whitelist=list(cfg.ip_whitelist),
|
||||
ip_whitelist_text=text,
|
||||
sync_fail2ban=cfg.sync_fail2ban,
|
||||
max_failures=cfg.max_failures,
|
||||
window_minutes=cfg.window_minutes,
|
||||
fail2ban_available=fail2ban_available(),
|
||||
source=cfg.source,
|
||||
)
|
||||
|
||||
|
||||
def _block_item(entry) -> LoginBlockItem:
|
||||
return LoginBlockItem(
|
||||
ip_address=entry.ip_address,
|
||||
scope=entry.scope,
|
||||
failure_count=entry.failure_count,
|
||||
usernames=list(entry.usernames),
|
||||
blocked_until=entry.blocked_until,
|
||||
reason=entry.reason,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/login-security", response_model=LoginSecuritySettingsResponse)
|
||||
def get_login_security_settings(
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> LoginSecuritySettingsResponse:
|
||||
return _login_security_response(db)
|
||||
|
||||
|
||||
@router.put("/login-security", response_model=LoginSecuritySettingsResponse)
|
||||
def update_login_security_settings(
|
||||
body: LoginSecuritySettingsUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> LoginSecuritySettingsResponse:
|
||||
from app.services.login_security_settings import upsert_login_security_settings
|
||||
|
||||
upsert_login_security_settings(
|
||||
db,
|
||||
ip_whitelist_text=body.ip_whitelist_text,
|
||||
sync_fail2ban=bool(body.sync_fail2ban),
|
||||
)
|
||||
return _login_security_response(db)
|
||||
|
||||
|
||||
@router.get("/login-security/blocks", response_model=list[LoginBlockItem])
|
||||
def list_login_security_blocks(
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> list[LoginBlockItem]:
|
||||
from app.services.login_security_settings import list_all_login_blocks
|
||||
|
||||
return [_block_item(e) for e in list_all_login_blocks(db)]
|
||||
|
||||
|
||||
@router.post("/login-security/unblock", response_model=LoginUnblockResponse)
|
||||
def unblock_login_security_ip(
|
||||
body: LoginUnblockRequest,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> LoginUnblockResponse:
|
||||
from app.services.login_security_settings import unblock_login_ip
|
||||
|
||||
scope = (body.scope or "both").strip().lower()
|
||||
if scope not in ("web", "ssh", "both"):
|
||||
raise HTTPException(status_code=422, detail="scope must be web, ssh, or both")
|
||||
messages = unblock_login_ip(db, body.ip_address, scope=scope)
|
||||
return LoginUnblockResponse(messages=messages)
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import asyncio
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi import APIRouter, Cookie, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth.jwt_auth import verify_access_token
|
||||
from app.auth.stream_cookie import STREAM_COOKIE_NAME
|
||||
from app.database import SessionLocal, get_db
|
||||
from app.models import Event, Problem
|
||||
from app.config import get_settings
|
||||
@@ -67,9 +68,12 @@ async def _sse_generator():
|
||||
|
||||
|
||||
def _sse_auth(
|
||||
token: str = Query(..., description="JWT access_token"),
|
||||
sac_stream: str | None = Cookie(None, alias=STREAM_COOKIE_NAME),
|
||||
db: Session = Depends(get_db),
|
||||
) -> str:
|
||||
token = (sac_stream or "").strip()
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="SSE authentication required")
|
||||
return verify_access_token(token, db=db)
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import hashlib
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
|
||||
from fastapi import Depends, HTTPException, Security
|
||||
@@ -6,16 +7,28 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.database import get_db
|
||||
from app.models import ApiKey
|
||||
|
||||
security_scheme = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def hash_api_key(raw_key: str) -> str:
|
||||
def _legacy_hash_api_key(raw_key: str) -> str:
|
||||
return hashlib.sha256(raw_key.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def hash_api_key(raw_key: str) -> str:
|
||||
secret = get_settings().jwt_secret.encode("utf-8")
|
||||
return hmac.new(secret, raw_key.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def verify_api_key_hash(raw_key: str, stored_hash: str) -> bool:
|
||||
if hmac.compare_digest(hash_api_key(raw_key), stored_hash):
|
||||
return True
|
||||
return hmac.compare_digest(_legacy_hash_api_key(raw_key), stored_hash)
|
||||
|
||||
|
||||
def generate_api_key() -> tuple[str, str, str]:
|
||||
"""Returns (full_key, prefix, hash)."""
|
||||
raw = f"sac_{secrets.token_urlsafe(32)}"
|
||||
@@ -24,9 +37,11 @@ def generate_api_key() -> tuple[str, str, str]:
|
||||
|
||||
|
||||
def verify_api_key(db: Session, raw_key: str) -> bool:
|
||||
key_hash = hash_api_key(raw_key)
|
||||
row = db.scalar(select(ApiKey).where(ApiKey.key_hash == key_hash, ApiKey.is_active.is_(True)))
|
||||
return row is not None
|
||||
rows = db.scalars(select(ApiKey).where(ApiKey.is_active.is_(True))).all()
|
||||
for row in rows:
|
||||
if verify_api_key_hash(raw_key, row.key_hash):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_api_key_auth(
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""httpOnly cookie auth for SSE (EventSource cannot send Authorization header)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Response
|
||||
|
||||
from app.config import Settings
|
||||
|
||||
STREAM_COOKIE_NAME = "sac_stream"
|
||||
STREAM_COOKIE_PATH = "/api/v1/stream"
|
||||
|
||||
|
||||
def stream_cookie_kwargs(settings: Settings) -> dict[str, object]:
|
||||
secure = settings.sac_public_url.lower().startswith("https://")
|
||||
return {
|
||||
"key": STREAM_COOKIE_NAME,
|
||||
"httponly": True,
|
||||
"secure": secure,
|
||||
"samesite": "strict",
|
||||
"path": STREAM_COOKIE_PATH,
|
||||
"max_age": max(60, int(settings.jwt_expire_minutes) * 60),
|
||||
}
|
||||
|
||||
|
||||
def set_stream_auth_cookie(response: Response, token: str, settings: Settings) -> None:
|
||||
response.set_cookie(value=token, **stream_cookie_kwargs(settings))
|
||||
|
||||
|
||||
def clear_stream_auth_cookie(response: Response, settings: Settings) -> None:
|
||||
kwargs = stream_cookie_kwargs(settings)
|
||||
response.delete_cookie(
|
||||
key=kwargs["key"],
|
||||
path=kwargs["path"],
|
||||
secure=bool(kwargs["secure"]),
|
||||
httponly=True,
|
||||
samesite="strict",
|
||||
)
|
||||
+48
-1
@@ -1,4 +1,4 @@
|
||||
import os
|
||||
import os
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
@@ -34,10 +34,18 @@ class Settings(BaseSettings):
|
||||
model_config = _settings_config()
|
||||
|
||||
database_url: str = "postgresql+psycopg2://sac:sac@localhost:5432/sac"
|
||||
sac_db_pool_size: int = 15
|
||||
sac_db_max_overflow: int = 25
|
||||
sac_uvicorn_workers: int = 4
|
||||
sac_public_url: str = "http://localhost:8000"
|
||||
# URL для скачивания RDP bundle с ПК (WinRM). По умолчанию = SAC_PUBLIC_URL.
|
||||
sac_agent_bundle_base_url: str = ""
|
||||
jwt_secret: str = "change-me-in-production"
|
||||
jwt_algorithm: str = "HS256"
|
||||
jwt_expire_minutes: int = 60 * 24
|
||||
# Fail-fast on weak JWT/CORS defaults (disable only for local dev/tests).
|
||||
sac_security_enforce: bool = True
|
||||
sac_ingest_max_body_bytes: int = 2_097_152
|
||||
|
||||
sac_bootstrap_api_key: str = ""
|
||||
sac_admin_username: str = "admin"
|
||||
@@ -102,6 +110,41 @@ class Settings(BaseSettings):
|
||||
sac_privilege_spike_window_minutes: int = 10
|
||||
sac_privilege_spike_threshold: int = 10
|
||||
|
||||
# rule:rdg_session_flap — RD Gateway 302→303 within window
|
||||
sac_rdg_flap_window_min_sec: int = 1
|
||||
sac_rdg_flap_window_max_sec: int = 10
|
||||
sac_rdg_flap_dedup_sec: int = 30
|
||||
# Внешние IP HAProxy/RDG-прокси: если external_ip в списке — путь Haproxy-RDG-Comp, иначе RDG-Comp
|
||||
sac_rdg_haproxy_external_ips: str = ""
|
||||
|
||||
# Windows admin for agent qwinsta/logoff (domain-wide)
|
||||
sac_win_admin_user: str = ""
|
||||
sac_win_admin_password: str = ""
|
||||
sac_winrm_use_https: bool = False
|
||||
sac_winrm_server_cert_validation: str = "validate"
|
||||
|
||||
# Linux SSH admin for remote agent update (fallback SSH, phase 5)
|
||||
sac_linux_admin_user: str = ""
|
||||
sac_linux_admin_password: str = ""
|
||||
sac_ssh_known_hosts_file: str = "/opt/security-alert-center/config/ssh_known_hosts"
|
||||
sac_ssh_auto_add_host_key: bool = False
|
||||
|
||||
# Agent updates (mode gpo|sac, recommended versions, WinRM fallback script)
|
||||
sac_agent_update_mode: str = "gpo"
|
||||
sac_agent_update_fallback_enabled: bool = True
|
||||
sac_agent_update_fallback_minutes: int = 15
|
||||
sac_agent_recommended_rdp_version: str = ""
|
||||
sac_agent_recommended_ssh_version: str = ""
|
||||
sac_agent_min_rdp_version: str = ""
|
||||
sac_agent_min_ssh_version: str = ""
|
||||
sac_win_agent_update_script: str = ""
|
||||
sac_agent_rdp_git_repo_url: str = "https://git.kalinamall.ru/PapaTramp/RDP-login-monitor.git"
|
||||
sac_agent_ssh_git_repo_url: str = "https://git.kalinamall.ru/PapaTramp/ssh-monitor.git"
|
||||
sac_agent_git_branch: str = "main"
|
||||
sac_agent_git_cache_dir: str = "/opt/security-alert-center/cache/agent-repos"
|
||||
sac_agent_git_cache_ttl_minutes: int = 30
|
||||
sac_agent_git_token: str = ""
|
||||
|
||||
# Retention (app.jobs.retention / systemd timer)
|
||||
sac_events_retention_days: int = 90
|
||||
sac_problems_retention_days: int = 180
|
||||
@@ -110,6 +153,10 @@ class Settings(BaseSettings):
|
||||
sac_login_max_failures: int = 3
|
||||
sac_login_failure_window_minutes: int = 15
|
||||
sac_login_alert_telegram: bool = True
|
||||
sac_login_ip_whitelist: str = ""
|
||||
sac_fail2ban_ssh_jail: str = "sshd"
|
||||
sac_fail2ban_sync_enabled: bool = False
|
||||
sac_fail2ban_ignoreip_file: str = "/etc/fail2ban/jail.d/sac-login-whitelist.local"
|
||||
|
||||
# Seaca mobile (FCM push)
|
||||
sac_fcm_enabled: bool = False
|
||||
|
||||
@@ -23,6 +23,7 @@ DEFAULT_EVENT_SEVERITIES: dict[str, str] = {
|
||||
# RDP / Windows
|
||||
"rdp.login.success": "info",
|
||||
"rdp.login.failed": "warning",
|
||||
"rdp.session.logoff": "info",
|
||||
"rdp.shadow.control.started": "warning",
|
||||
"rdp.shadow.control.stopped": "info",
|
||||
"rdp.shadow.control.permission": "warning",
|
||||
|
||||
@@ -6,7 +6,11 @@ from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
engine = create_engine(settings.database_url, pool_pre_ping=True)
|
||||
_engine_kwargs: dict = {"pool_pre_ping": True}
|
||||
if settings.database_url.startswith("postgresql"):
|
||||
_engine_kwargs["pool_size"] = max(1, int(settings.sac_db_pool_size))
|
||||
_engine_kwargs["max_overflow"] = max(0, int(settings.sac_db_max_overflow))
|
||||
engine = create_engine(settings.database_url, **_engine_kwargs)
|
||||
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
|
||||
|
||||
|
||||
|
||||
@@ -36,6 +36,9 @@ def _run_scan_once() -> None:
|
||||
try:
|
||||
results = run_host_silence_scan(db)
|
||||
notified = dispatch_host_silence_notifications(db, results)
|
||||
from app.services.agent_update import process_agent_update_fallbacks
|
||||
|
||||
fallback_results = process_agent_update_fallbacks(db)
|
||||
db.commit()
|
||||
if results:
|
||||
logger.info(
|
||||
@@ -44,6 +47,12 @@ def _run_scan_once() -> None:
|
||||
sum(1 for r in results if r.created),
|
||||
notified,
|
||||
)
|
||||
if fallback_results:
|
||||
logger.info(
|
||||
"agent_update fallback: processed=%s ok=%s",
|
||||
len(fallback_results),
|
||||
sum(1 for r in fallback_results if r.get("ok")),
|
||||
)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
+26
-4
@@ -1,4 +1,4 @@
|
||||
import asyncio
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager, suppress
|
||||
from pathlib import Path
|
||||
@@ -13,7 +13,9 @@ from app.api.v1.router import api_router
|
||||
from app.auth.api_key import hash_api_key
|
||||
from app.config import get_settings
|
||||
from app.database import SessionLocal
|
||||
from app.middleware.ingest_body_limit import IngestBodySizeLimitMiddleware
|
||||
from app.models import ApiKey
|
||||
from app.security_bootstrap import validate_security_settings, warn_relaxed_security
|
||||
from app.services.user_auth import bootstrap_admin_user
|
||||
from app.version import APP_VERSION, APP_VERSION_LABEL
|
||||
|
||||
@@ -53,15 +55,30 @@ def bootstrap_users() -> None:
|
||||
db.close()
|
||||
|
||||
|
||||
def bootstrap_stale_remote_actions() -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
from app.services.host_remote_actions import clear_stale_running_remote_actions
|
||||
|
||||
cleared = clear_stale_running_remote_actions(db)
|
||||
for hostname in cleared:
|
||||
logger.info("Stale remote action reset on startup: %s", hostname)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
settings = get_settings()
|
||||
validate_security_settings(settings)
|
||||
warn_relaxed_security(settings)
|
||||
logger.info("%s — application startup (version %s)", APP_VERSION_LABEL, APP_VERSION)
|
||||
bootstrap_api_key()
|
||||
bootstrap_users()
|
||||
bootstrap_stale_remote_actions()
|
||||
|
||||
stop_scan = asyncio.Event()
|
||||
scan_task: asyncio.Task | None = None
|
||||
settings = get_settings()
|
||||
if settings.sac_host_silence_scan_enabled:
|
||||
from app.jobs.host_silence_background import host_silence_scan_loop
|
||||
|
||||
@@ -95,13 +112,18 @@ def create_app() -> FastAPI:
|
||||
lifespan=lifespan,
|
||||
)
|
||||
origins = [o.strip() for o in settings.cors_origins.split(",") if o.strip()]
|
||||
wildcard = origins == ["*"]
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=origins if origins != ["*"] else ["*"],
|
||||
allow_credentials=True,
|
||||
allow_origins=origins if not wildcard else ["*"],
|
||||
allow_credentials=not wildcard,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
app.add_middleware(
|
||||
IngestBodySizeLimitMiddleware,
|
||||
max_bytes=settings.sac_ingest_max_body_bytes,
|
||||
)
|
||||
from app.api.v1.health import router as health_router
|
||||
|
||||
app.include_router(api_router, prefix="/api/v1")
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Limit POST /api/v1/events body size (matches nginx client_max_body_size)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
|
||||
class IngestBodySizeLimitMiddleware(BaseHTTPMiddleware):
|
||||
def __init__(self, app, *, max_bytes: int = 2_097_152) -> None:
|
||||
super().__init__(app)
|
||||
self._max_bytes = max_bytes
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
if request.method == "POST" and request.url.path.rstrip("/") == "/api/v1/events":
|
||||
content_length = request.headers.get("content-length")
|
||||
if content_length:
|
||||
try:
|
||||
size = int(content_length)
|
||||
except ValueError:
|
||||
size = 0
|
||||
if size > self._max_bytes:
|
||||
return JSONResponse(
|
||||
status_code=413,
|
||||
content={"detail": f"Request body too large (max {self._max_bytes} bytes)"},
|
||||
)
|
||||
return await call_next(request)
|
||||
@@ -1,3 +1,4 @@
|
||||
from app.models.agent_command import AgentCommand
|
||||
from app.models.api_key import ApiKey
|
||||
from app.models.event import Event
|
||||
from app.models.host import Host
|
||||
@@ -8,6 +9,7 @@ from app.models.problem import Problem, ProblemEvent
|
||||
from app.models.login_attempt import LoginAttempt
|
||||
from app.models.ui_settings import UiSettings
|
||||
from app.models.event_severity_override import EventSeverityOverride
|
||||
from app.models.event_type_visibility import EventTypeVisibility
|
||||
from app.models.user import User
|
||||
from app.models.mobile_settings import MobileSettings
|
||||
from app.models.mobile_enrollment_code import MobileEnrollmentCode
|
||||
@@ -16,6 +18,7 @@ from app.models.mobile_refresh_token import MobileRefreshToken
|
||||
|
||||
__all__ = [
|
||||
"ApiKey",
|
||||
"AgentCommand",
|
||||
"Event",
|
||||
"Host",
|
||||
"NotificationChannel",
|
||||
@@ -25,6 +28,7 @@ __all__ = [
|
||||
"ProblemEvent",
|
||||
"User",
|
||||
"EventSeverityOverride",
|
||||
"EventTypeVisibility",
|
||||
"LoginAttempt",
|
||||
"UiSettings",
|
||||
"MobileSettings",
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class AgentCommand(Base):
|
||||
__tablename__ = "agent_commands"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
command_uuid: Mapped[str] = mapped_column(String(36), unique=True, index=True)
|
||||
host_id: Mapped[int] = mapped_column(ForeignKey("hosts.id", ondelete="CASCADE"), index=True)
|
||||
event_id: Mapped[int | None] = mapped_column(ForeignKey("events.id", ondelete="SET NULL"), nullable=True)
|
||||
command_type: Mapped[str] = mapped_column(String(32))
|
||||
params: Mapped[dict | None] = mapped_column(JSONB, default=dict)
|
||||
status: Mapped[str] = mapped_column(String(16), default="pending", index=True)
|
||||
result_stdout: Mapped[str | None] = mapped_column(Text)
|
||||
result_stderr: Mapped[str | None] = mapped_column(Text)
|
||||
requested_by: Mapped[str | None] = mapped_column(String(128))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
host: Mapped["Host"] = relationship(back_populates="agent_commands")
|
||||
event: Mapped["Event | None"] = relationship()
|
||||
@@ -0,0 +1,21 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class EventTypeVisibility(Base):
|
||||
"""Per event-type UI visibility (default: show in events lists)."""
|
||||
|
||||
__tablename__ = "event_type_visibility"
|
||||
|
||||
event_type: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||
show_in_events: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
@@ -24,7 +24,22 @@ class Host(Base):
|
||||
inventory: Mapped[dict | None] = mapped_column(JSONB)
|
||||
inventory_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
use_sac_mode: Mapped[str | None] = mapped_column(String(32))
|
||||
ssh_admin_ok: Mapped[bool | None] = mapped_column(nullable=True)
|
||||
ssh_admin_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
# Optional override for SSH/WinRM (home PCs, unique local admin). Empty → global Settings.
|
||||
mgmt_user: Mapped[str | None] = mapped_column(String(256))
|
||||
mgmt_password: Mapped[str | None] = mapped_column(Text)
|
||||
pending_agent_update: Mapped[bool] = mapped_column(default=False, server_default="false")
|
||||
pending_update_requested_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
pending_update_target_version: Mapped[str | None] = mapped_column(String(64))
|
||||
agent_config: Mapped[dict | None] = mapped_column(JSONB)
|
||||
agent_config_revision: Mapped[int] = mapped_column(default=0, server_default="0")
|
||||
agent_update_state: Mapped[str | None] = mapped_column(String(32))
|
||||
agent_update_last_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
agent_update_last_error: Mapped[str | None] = mapped_column(Text)
|
||||
remote_action: Mapped[dict | None] = mapped_column(JSONB)
|
||||
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
events: Mapped[list["Event"]] = relationship(back_populates="host")
|
||||
agent_commands: Mapped[list["AgentCommand"]] = relationship(back_populates="host")
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from sqlalchemy import Boolean
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
@@ -13,3 +15,26 @@ class UiSettings(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
show_sidebar_system_stats: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
win_admin_user: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
win_admin_password: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
linux_admin_user: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
linux_admin_password: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
agent_update_mode: Mapped[str] = mapped_column(String(16), default="gpo", nullable=False)
|
||||
agent_update_fallback_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
agent_update_fallback_minutes: Mapped[int] = mapped_column(default=15, nullable=False)
|
||||
agent_recommended_rdp_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
agent_recommended_ssh_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
agent_min_rdp_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
agent_min_ssh_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
win_agent_update_script: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
agent_rdp_git_repo_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
agent_ssh_git_repo_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
agent_git_branch: Mapped[str] = mapped_column(String(64), default="main", nullable=False)
|
||||
agent_git_rdp_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
agent_git_ssh_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
agent_git_fetched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
login_ip_whitelist: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
login_sync_fail2ban: Mapped[bool] = mapped_column(default=False, server_default="false", nullable=False)
|
||||
auto_rdp_flap_disconnect: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default="false", nullable=False
|
||||
)
|
||||
|
||||
@@ -18,6 +18,8 @@ class HostSummary(BaseModel):
|
||||
last_daily_report_at: datetime | None = None
|
||||
last_inventory_at: datetime | None = None
|
||||
agent_status: str = "unknown"
|
||||
version_status: str = "unknown"
|
||||
pending_agent_update: bool = False
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@@ -30,6 +32,15 @@ class HostDetail(HostSummary):
|
||||
inventory: dict | None = None
|
||||
inventory_updated_at: datetime | None = None
|
||||
created_at: datetime | None = None
|
||||
ssh_admin_ok: bool | None = None
|
||||
ssh_admin_checked_at: datetime | None = None
|
||||
pending_update_requested_at: datetime | None = None
|
||||
pending_update_target_version: str | None = None
|
||||
agent_config_revision: int = 0
|
||||
agent_config: dict | None = None
|
||||
agent_update_state: str | None = None
|
||||
agent_update_last_at: datetime | None = None
|
||||
agent_update_last_error: str | None = None
|
||||
|
||||
|
||||
class HostListResponse(BaseModel):
|
||||
@@ -38,6 +49,18 @@ class HostListResponse(BaseModel):
|
||||
page: int
|
||||
page_size: int
|
||||
latest_agent_versions: dict[str, str] = Field(default_factory=dict)
|
||||
reference_agent_versions: dict[str, str] = Field(
|
||||
default_factory=dict,
|
||||
description="Эталон для «устарела»: max(git latest, min, max в fleet)",
|
||||
)
|
||||
git_latest_rdp_version: str | None = Field(
|
||||
None,
|
||||
description="Последняя версия rdp-login-monitor из git (version.txt / main)",
|
||||
)
|
||||
git_latest_ssh_version: str | None = Field(
|
||||
None,
|
||||
description="Последняя версия ssh-monitor из git (version.txt / main)",
|
||||
)
|
||||
|
||||
|
||||
class EventSummary(BaseModel):
|
||||
@@ -55,6 +78,13 @@ class EventSummary(BaseModel):
|
||||
title: str
|
||||
summary: str
|
||||
actor_user: str | None = None
|
||||
session_duration_sec: int | None = None
|
||||
rdg_flap: bool = False
|
||||
rdg_flap_pair_event_id: int | None = None
|
||||
rdg_flap_qwinsta_event_id: int | None = None
|
||||
rdg_access_path: str | None = None
|
||||
rdg_qwinsta_enabled: bool = False
|
||||
session_terminated: bool = False
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Fail-fast checks for insecure production defaults."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from app.config import Settings
|
||||
|
||||
logger = logging.getLogger("sac")
|
||||
|
||||
_WEAK_JWT_SECRETS = frozenset(
|
||||
{
|
||||
"",
|
||||
"change-me-in-production",
|
||||
"change-me-openssl-rand-hex-32",
|
||||
"CHANGE_ME_openssl_rand_hex_32",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _is_local_dev_url(public_url: str) -> bool:
|
||||
parsed = urlparse(public_url.strip() or "http://localhost:8000")
|
||||
host = (parsed.hostname or "").lower()
|
||||
return host in {"localhost", "127.0.0.1", "::1", "testserver"}
|
||||
|
||||
|
||||
def validate_security_settings(settings: Settings) -> None:
|
||||
"""Raise on dangerous defaults when SAC_SECURITY_ENFORCE=true."""
|
||||
if not settings.sac_security_enforce:
|
||||
return
|
||||
|
||||
if settings.jwt_secret in _WEAK_JWT_SECRETS:
|
||||
raise RuntimeError(
|
||||
"JWT_SECRET is missing or uses an insecure default. "
|
||||
"Set a random value in sac-api.env (openssl rand -hex 32)."
|
||||
)
|
||||
|
||||
if settings.cors_origins.strip() == "*" and not _is_local_dev_url(settings.sac_public_url):
|
||||
raise RuntimeError(
|
||||
"CORS_ORIGINS=* is not allowed with SAC_SECURITY_ENFORCE=true on non-local SAC_PUBLIC_URL. "
|
||||
"Set CORS_ORIGINS to your UI origin, e.g. https://sac.example.com"
|
||||
)
|
||||
|
||||
|
||||
def warn_relaxed_security(settings: Settings) -> None:
|
||||
if settings.sac_security_enforce:
|
||||
return
|
||||
if settings.jwt_secret in _WEAK_JWT_SECRETS:
|
||||
logger.warning("JWT_SECRET uses insecure default — set a strong secret before production")
|
||||
if settings.cors_origins.strip() == "*":
|
||||
logger.warning("CORS_ORIGINS=* — restrict to explicit origins in production")
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Queue and resolve agent commands (legacy poll path + helpers)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import AgentCommand, Host
|
||||
from app.services.win_admin_settings import (
|
||||
get_effective_win_admin_config,
|
||||
get_effective_win_admin_for_host,
|
||||
)
|
||||
|
||||
|
||||
def _win_admin_configured() -> bool:
|
||||
return get_effective_win_admin_config().configured
|
||||
|
||||
|
||||
def win_admin_run_as(db: Session | None = None, host: Host | None = None) -> dict[str, str] | None:
|
||||
if host is not None and db is not None:
|
||||
cfg = get_effective_win_admin_for_host(db, host)
|
||||
else:
|
||||
cfg = get_effective_win_admin_config(db)
|
||||
if not cfg.configured:
|
||||
return None
|
||||
return {
|
||||
"user": cfg.user,
|
||||
"password": cfg.password,
|
||||
}
|
||||
|
||||
|
||||
def command_to_dict(
|
||||
cmd: AgentCommand,
|
||||
*,
|
||||
include_run_as: bool = False,
|
||||
db: Session | None = None,
|
||||
host: Host | None = None,
|
||||
) -> dict:
|
||||
out = {
|
||||
"id": cmd.command_uuid,
|
||||
"type": cmd.command_type,
|
||||
"params": cmd.params if isinstance(cmd.params, dict) else {},
|
||||
"status": cmd.status,
|
||||
"result_stdout": cmd.result_stdout,
|
||||
"result_stderr": cmd.result_stderr,
|
||||
"created_at": cmd.created_at.isoformat() if cmd.created_at else None,
|
||||
"completed_at": cmd.completed_at.isoformat() if cmd.completed_at else None,
|
||||
}
|
||||
if include_run_as and cmd.status == "pending":
|
||||
run_as = win_admin_run_as(db, host)
|
||||
if run_as:
|
||||
out["run_as"] = run_as
|
||||
return out
|
||||
|
||||
|
||||
def command_response_fields(cmd: AgentCommand) -> dict:
|
||||
params = cmd.params if isinstance(cmd.params, dict) else {}
|
||||
return {
|
||||
"target": params.get("winrm_target") or params.get("client_hostname"),
|
||||
"client_hostname": params.get("client_hostname"),
|
||||
"internal_ip": params.get("internal_ip"),
|
||||
}
|
||||
|
||||
|
||||
def get_command_for_host_poll(db: Session, host: Host, *, limit: int = 5) -> list[AgentCommand]:
|
||||
return list(
|
||||
db.scalars(
|
||||
select(AgentCommand)
|
||||
.where(
|
||||
AgentCommand.host_id == host.id,
|
||||
AgentCommand.status == "pending",
|
||||
)
|
||||
.order_by(AgentCommand.created_at.asc())
|
||||
.limit(limit)
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def resolve_host_by_agent_instance_id(db: Session, agent_instance_id: str) -> Host | None:
|
||||
if not agent_instance_id.strip():
|
||||
return None
|
||||
return db.scalar(select(Host).where(Host.agent_instance_id == agent_instance_id.strip()))
|
||||
|
||||
|
||||
def complete_command(
|
||||
db: Session,
|
||||
command_uuid: str,
|
||||
*,
|
||||
status: str,
|
||||
stdout: str | None = None,
|
||||
stderr: str | None = None,
|
||||
) -> AgentCommand | None:
|
||||
cmd = db.scalar(select(AgentCommand).where(AgentCommand.command_uuid == command_uuid))
|
||||
if cmd is None:
|
||||
return None
|
||||
if cmd.status != "pending":
|
||||
return cmd
|
||||
cmd.status = status
|
||||
cmd.result_stdout = stdout
|
||||
cmd.result_stderr = stderr
|
||||
cmd.completed_at = datetime.now(timezone.utc)
|
||||
db.flush()
|
||||
return cmd
|
||||
|
||||
|
||||
def get_command_by_uuid(db: Session, command_uuid: str) -> AgentCommand | None:
|
||||
return db.scalar(select(AgentCommand).where(AgentCommand.command_uuid == command_uuid))
|
||||
@@ -0,0 +1,308 @@
|
||||
"""Fetch latest agent release versions from git repositories."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
|
||||
from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH, AgentUpdateConfig
|
||||
from app.services.agent_version import parse_agent_version
|
||||
|
||||
_SCRIPT_VERSION_RE = re.compile(r'\$ScriptVersion\s*=\s*["\']([^"\']+)["\']', re.IGNORECASE)
|
||||
_SSH_VERSION_RE = re.compile(r'^SSH_MONITOR_VERSION=["\']([^"\']+)["\']', re.MULTILINE)
|
||||
|
||||
_MEMORY_CACHE: dict[str, tuple[dict[str, str], float, str]] = {}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GitReleaseVersions:
|
||||
versions: dict[str, str]
|
||||
fetched_at: datetime | None
|
||||
from_cache: bool
|
||||
errors: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
def normalize_git_repo_url(raw: str) -> str:
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
if not text.startswith(("http://", "https://", "git@")):
|
||||
text = f"https://{text.lstrip('/')}"
|
||||
if text.startswith("git@"):
|
||||
return text
|
||||
if not text.endswith(".git"):
|
||||
text = f"{text.rstrip('/')}.git"
|
||||
return text
|
||||
|
||||
|
||||
def git_repo_url_with_auth(repo_url: str, token: str) -> str:
|
||||
normalized = normalize_git_repo_url(repo_url)
|
||||
if not token or not normalized.startswith("https://"):
|
||||
return normalized
|
||||
parsed = urlparse(normalized)
|
||||
host = parsed.netloc
|
||||
path = parsed.path or ""
|
||||
return f"https://oauth2:{token}@{host}{path}"
|
||||
|
||||
|
||||
def _cache_key(cfg: AgentUpdateConfig) -> str:
|
||||
parts = [
|
||||
cfg.rdp_git_repo_url,
|
||||
cfg.ssh_git_repo_url,
|
||||
cfg.git_branch,
|
||||
]
|
||||
return hashlib.sha256("|".join(parts).encode()).hexdigest()
|
||||
|
||||
|
||||
def _read_text(path: Path) -> str | None:
|
||||
if not path.is_file():
|
||||
return None
|
||||
return path.read_text(encoding="utf-8-sig")
|
||||
|
||||
|
||||
def parse_rdp_version_from_files(version_txt: str | None, login_monitor_ps1: str | None) -> str | None:
|
||||
if version_txt:
|
||||
first_line = version_txt.strip().splitlines()[0].strip() if version_txt.strip() else ""
|
||||
if parse_agent_version(first_line):
|
||||
return first_line
|
||||
if login_monitor_ps1:
|
||||
match = _SCRIPT_VERSION_RE.search(login_monitor_ps1)
|
||||
if match:
|
||||
candidate = match.group(1).strip()
|
||||
if parse_agent_version(candidate):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def parse_ssh_version_from_files(ssh_monitor: str | None, version_txt: str | None) -> str | None:
|
||||
if ssh_monitor:
|
||||
match = _SSH_VERSION_RE.search(ssh_monitor)
|
||||
if match:
|
||||
candidate = match.group(1).strip()
|
||||
if parse_agent_version(candidate):
|
||||
return candidate
|
||||
if version_txt:
|
||||
first_line = version_txt.strip().splitlines()[0].strip() if version_txt.strip() else ""
|
||||
if parse_agent_version(first_line):
|
||||
return first_line
|
||||
return None
|
||||
|
||||
|
||||
def parse_product_version_from_dir(repo_dir: Path, product: str) -> str | None:
|
||||
if product == PRODUCT_RDP:
|
||||
return parse_rdp_version_from_files(
|
||||
_read_text(repo_dir / "version.txt"),
|
||||
_read_text(repo_dir / "Login_Monitor.ps1"),
|
||||
)
|
||||
if product == PRODUCT_SSH:
|
||||
return parse_ssh_version_from_files(
|
||||
_read_text(repo_dir / "ssh-monitor"),
|
||||
_read_text(repo_dir / "version.txt"),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _run_git(args: list[str], *, cwd: Path | None = None) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
["git", *args],
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def _repo_slug(repo_url: str) -> str:
|
||||
normalized = normalize_git_repo_url(repo_url)
|
||||
name = normalized.rstrip("/").split("/")[-1]
|
||||
if name.endswith(".git"):
|
||||
name = name[:-4]
|
||||
digest = hashlib.sha256(normalized.encode()).hexdigest()[:12]
|
||||
return f"{name}-{digest}"
|
||||
|
||||
|
||||
def clone_or_update_repo(repo_url: str, branch: str, cache_dir: Path, token: str = "") -> Path:
|
||||
clone_url = git_repo_url_with_auth(repo_url, token)
|
||||
normalized = normalize_git_repo_url(repo_url)
|
||||
if not normalized:
|
||||
raise ValueError("empty repo url")
|
||||
|
||||
branch_name = (branch or "main").strip() or "main"
|
||||
target = cache_dir / _repo_slug(normalized)
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if target.is_dir() and (target / ".git").is_dir():
|
||||
fetch = _run_git(["fetch", "--depth", "1", "origin", branch_name], cwd=target)
|
||||
if fetch.returncode != 0:
|
||||
shutil.rmtree(target, ignore_errors=True)
|
||||
else:
|
||||
checkout = _run_git(["checkout", "FETCH_HEAD"], cwd=target)
|
||||
if checkout.returncode == 0:
|
||||
return target
|
||||
shutil.rmtree(target, ignore_errors=True)
|
||||
|
||||
clone = _run_git(
|
||||
["clone", "--depth", "1", "--branch", branch_name, clone_url, str(target)],
|
||||
)
|
||||
if clone.returncode != 0:
|
||||
err = (clone.stderr or clone.stdout or "git clone failed").strip()
|
||||
raise RuntimeError(err)
|
||||
return target
|
||||
|
||||
|
||||
def fetch_product_version(repo_url: str, branch: str, product: str, cache_dir: Path, token: str = "") -> str:
|
||||
repo_dir = clone_or_update_repo(repo_url, branch, cache_dir, token=token)
|
||||
version = parse_product_version_from_dir(repo_dir, product)
|
||||
if not version:
|
||||
raise RuntimeError(f"version not found in {repo_url} ({product})")
|
||||
return version
|
||||
|
||||
|
||||
def recommended_from_git(cfg: AgentUpdateConfig, git_versions: dict[str, str], product: str) -> str:
|
||||
git_version = (git_versions.get(product) or "").strip()
|
||||
if git_version:
|
||||
return git_version
|
||||
manual = cfg.recommended_for_product(product)
|
||||
return manual
|
||||
|
||||
|
||||
def _ttl_seconds() -> int:
|
||||
settings = get_settings()
|
||||
return max(60, int(settings.sac_agent_git_cache_ttl_minutes) * 60)
|
||||
|
||||
|
||||
def _cache_dir() -> Path:
|
||||
settings = get_settings()
|
||||
return Path(settings.sac_agent_git_cache_dir or "/tmp/sac-agent-repos")
|
||||
|
||||
|
||||
def _db_cache_fresh(row: UiSettings | None, cfg: AgentUpdateConfig) -> bool:
|
||||
if row is None or row.agent_git_fetched_at is None:
|
||||
return False
|
||||
fetched_at = row.agent_git_fetched_at
|
||||
if fetched_at.tzinfo is None:
|
||||
fetched_at = fetched_at.replace(tzinfo=timezone.utc)
|
||||
age = (datetime.now(timezone.utc) - fetched_at).total_seconds()
|
||||
if age > _ttl_seconds():
|
||||
return False
|
||||
if (row.agent_rdp_git_repo_url or "").strip() != cfg.rdp_git_repo_url:
|
||||
return False
|
||||
if (row.agent_ssh_git_repo_url or "").strip() != cfg.ssh_git_repo_url:
|
||||
return False
|
||||
if (row.agent_git_branch or "main").strip() != cfg.git_branch:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _versions_from_db_row(row: UiSettings) -> dict[str, str]:
|
||||
out: dict[str, str] = {}
|
||||
rdp = (row.agent_git_rdp_version or "").strip()
|
||||
ssh = (row.agent_git_ssh_version or "").strip()
|
||||
if rdp:
|
||||
out[PRODUCT_RDP] = rdp
|
||||
if ssh:
|
||||
out[PRODUCT_SSH] = ssh
|
||||
return out
|
||||
|
||||
|
||||
def _persist_db_cache(db: Session, cfg: AgentUpdateConfig, versions: dict[str, str]) -> datetime:
|
||||
now = datetime.now(timezone.utc)
|
||||
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
|
||||
if row is None:
|
||||
row = UiSettings(id=UI_SETTINGS_ROW_ID, show_sidebar_system_stats=True)
|
||||
db.add(row)
|
||||
row.agent_rdp_git_repo_url = cfg.rdp_git_repo_url or None
|
||||
row.agent_ssh_git_repo_url = cfg.ssh_git_repo_url or None
|
||||
row.agent_git_branch = cfg.git_branch or "main"
|
||||
row.agent_git_rdp_version = versions.get(PRODUCT_RDP) or None
|
||||
row.agent_git_ssh_version = versions.get(PRODUCT_SSH) or None
|
||||
row.agent_git_fetched_at = now
|
||||
db.commit()
|
||||
return now
|
||||
|
||||
|
||||
def get_git_release_versions(
|
||||
cfg: AgentUpdateConfig,
|
||||
*,
|
||||
db: Session | None = None,
|
||||
force_refresh: bool = False,
|
||||
) -> GitReleaseVersions:
|
||||
key = _cache_key(cfg)
|
||||
now_ts = time.time()
|
||||
ttl = _ttl_seconds()
|
||||
|
||||
if not force_refresh:
|
||||
cached = _MEMORY_CACHE.get(key)
|
||||
if cached and now_ts - cached[1] <= ttl:
|
||||
fetched_at = datetime.fromtimestamp(cached[1], tz=timezone.utc)
|
||||
return GitReleaseVersions(versions=dict(cached[0]), fetched_at=fetched_at, from_cache=True)
|
||||
|
||||
if db is not None:
|
||||
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
|
||||
if _db_cache_fresh(row, cfg):
|
||||
versions = _versions_from_db_row(row)
|
||||
if versions:
|
||||
_MEMORY_CACHE[key] = (versions, row.agent_git_fetched_at.timestamp(), key)
|
||||
return GitReleaseVersions(
|
||||
versions=versions,
|
||||
fetched_at=row.agent_git_fetched_at,
|
||||
from_cache=True,
|
||||
)
|
||||
|
||||
settings = get_settings()
|
||||
cache_dir = _cache_dir()
|
||||
token = (settings.sac_agent_git_token or "").strip()
|
||||
branch = cfg.git_branch or "main"
|
||||
versions: dict[str, str] = {}
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
product_repo = {
|
||||
PRODUCT_RDP: cfg.rdp_git_repo_url,
|
||||
PRODUCT_SSH: cfg.ssh_git_repo_url,
|
||||
}
|
||||
for product, repo_url in product_repo.items():
|
||||
if not repo_url:
|
||||
errors[product] = "repo url not configured"
|
||||
continue
|
||||
try:
|
||||
versions[product] = fetch_product_version(repo_url, branch, product, cache_dir, token=token)
|
||||
except (OSError, RuntimeError, ValueError, subprocess.SubprocessError) as exc:
|
||||
errors[product] = str(exc).strip() or "fetch failed"
|
||||
|
||||
fetched_at: datetime | None = None
|
||||
if versions:
|
||||
fetched_at = datetime.now(timezone.utc)
|
||||
_MEMORY_CACHE[key] = (dict(versions), fetched_at.timestamp(), key)
|
||||
if db is not None:
|
||||
fetched_at = _persist_db_cache(db, cfg, versions)
|
||||
|
||||
if not versions and db is not None:
|
||||
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
|
||||
if row is not None:
|
||||
stale = _versions_from_db_row(row)
|
||||
if stale:
|
||||
return GitReleaseVersions(
|
||||
versions=stale,
|
||||
fetched_at=row.agent_git_fetched_at,
|
||||
from_cache=True,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
return GitReleaseVersions(
|
||||
versions=versions,
|
||||
fetched_at=fetched_at,
|
||||
from_cache=False,
|
||||
errors=errors,
|
||||
)
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Desired agent config per host (poll delivery)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from app.models import Host
|
||||
from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH
|
||||
|
||||
RDP_CONFIG_KEYS = frozenset(
|
||||
{
|
||||
"ServerDisplayName",
|
||||
"EnableRcmShadowControlMonitoring",
|
||||
"EnableWinRmInboundMonitoring",
|
||||
"EnableAdminShareMonitoring",
|
||||
"GetInventory",
|
||||
"SacCommandPollIntervalSec",
|
||||
}
|
||||
)
|
||||
SSH_CONFIG_KEYS = frozenset(
|
||||
{
|
||||
"SERVER_DISPLAY_NAME",
|
||||
"HEARTBEAT_INTERVAL",
|
||||
"DAILY_REPORT_ENABLED",
|
||||
"UseSAC",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _allowed_keys(host: Host) -> frozenset[str]:
|
||||
product = (host.product or "").strip()
|
||||
if product == PRODUCT_RDP or (host.os_family or "").strip().lower() == "windows":
|
||||
return RDP_CONFIG_KEYS
|
||||
if product == PRODUCT_SSH or (host.os_family or "").strip().lower() == "linux":
|
||||
return SSH_CONFIG_KEYS
|
||||
return frozenset()
|
||||
|
||||
|
||||
def get_host_agent_settings(host: Host) -> dict:
|
||||
raw = host.agent_config if isinstance(host.agent_config, dict) else {}
|
||||
allowed = _allowed_keys(host)
|
||||
if not allowed:
|
||||
return {}
|
||||
return {k: v for k, v in raw.items() if k in allowed}
|
||||
|
||||
|
||||
def build_agent_config_payload(host: Host) -> dict:
|
||||
settings = get_host_agent_settings(host)
|
||||
if not settings:
|
||||
return {}
|
||||
return {"settings": settings}
|
||||
|
||||
|
||||
def update_host_agent_config(db: Session, host: Host, settings: dict) -> Host:
|
||||
allowed = _allowed_keys(host)
|
||||
if not allowed:
|
||||
raise ValueError("Host product does not support desired agent config")
|
||||
|
||||
current = host.agent_config if isinstance(host.agent_config, dict) else {}
|
||||
merged = dict(current)
|
||||
for key, value in settings.items():
|
||||
if key not in allowed:
|
||||
raise ValueError(f"Unsupported config key: {key}")
|
||||
if value is None:
|
||||
merged.pop(key, None)
|
||||
else:
|
||||
merged[key] = value
|
||||
|
||||
host.agent_config = merged
|
||||
host.agent_config_revision = int(host.agent_config_revision or 0) + 1
|
||||
flag_modified(host, "agent_config")
|
||||
db.flush()
|
||||
return host
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Agent poll payload: commands, desired config, pending update."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Host
|
||||
from app.services.agent_commands import command_to_dict, get_command_for_host_poll
|
||||
from app.services.agent_host_config import build_agent_config_payload
|
||||
from app.services.agent_update_settings import get_effective_agent_update_config
|
||||
|
||||
|
||||
def build_agent_poll_payload(db: Session, host: Host) -> dict[str, Any]:
|
||||
cfg = get_effective_agent_update_config(db)
|
||||
pending = get_command_for_host_poll(db, host)
|
||||
config_payload = build_agent_config_payload(host)
|
||||
|
||||
update_block: dict[str, Any] = {
|
||||
"requested": bool(host.pending_agent_update),
|
||||
"target_version": host.pending_update_target_version,
|
||||
"source": "sac" if cfg.sac_managed else "gpo",
|
||||
}
|
||||
|
||||
return {
|
||||
"config_revision": int(host.agent_config_revision or 0),
|
||||
"config": config_payload,
|
||||
"update": update_block,
|
||||
"commands": [command_to_dict(c, include_run_as=True, db=db, host=host) for c in pending],
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
"""Request agent self-update, ingest lifecycle, SSH/WinRM fallback."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Host
|
||||
from app.services.agent_update_types import AgentUpdateFallbackResult
|
||||
from app.services.agent_update_settings import (
|
||||
PRODUCT_RDP,
|
||||
PRODUCT_SSH,
|
||||
AgentUpdateConfig,
|
||||
get_effective_agent_update_config,
|
||||
)
|
||||
from app.services.agent_git_release import get_git_release_versions, recommended_from_git
|
||||
from app.services.agent_version import (
|
||||
host_version_outdated,
|
||||
latest_agent_versions_by_product,
|
||||
)
|
||||
from app.services.linux_admin_settings import get_effective_linux_admin_for_host
|
||||
from app.services.ssh_connect import (
|
||||
HostNotLinuxError,
|
||||
HostTargetMissingError,
|
||||
is_linux_host,
|
||||
iter_ssh_targets,
|
||||
run_ssh_monitor_update,
|
||||
)
|
||||
from app.services.win_admin_settings import get_effective_win_admin_for_host
|
||||
from app.services.winrm_connect import (
|
||||
HostNotWindowsError,
|
||||
HostTargetMissingError as WinRmHostTargetMissingError,
|
||||
is_windows_host,
|
||||
iter_winrm_targets,
|
||||
run_winrm_on_host_targets,
|
||||
run_winrm_rdp_monitor_update,
|
||||
)
|
||||
|
||||
AGENT_UPDATE_STARTED = "agent.update.started"
|
||||
AGENT_UPDATE_SUCCESS = "agent.update.success"
|
||||
AGENT_UPDATE_FAILED = "agent.update.failed"
|
||||
AGENT_UPDATE_TYPES = frozenset(
|
||||
{AGENT_UPDATE_STARTED, AGENT_UPDATE_SUCCESS, AGENT_UPDATE_FAILED}
|
||||
)
|
||||
|
||||
|
||||
class AgentUpdateNotAllowedError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def resolve_target_version(
|
||||
db: Session,
|
||||
host: Host,
|
||||
cfg: AgentUpdateConfig,
|
||||
*,
|
||||
git_versions: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
product = host.product or ""
|
||||
if git_versions is None:
|
||||
git_versions = get_git_release_versions(cfg, db=db).versions
|
||||
git_target = recommended_from_git(cfg, git_versions, product)
|
||||
if git_target:
|
||||
return git_target
|
||||
latest_map = latest_agent_versions_by_product(db)
|
||||
return latest_map.get(product, "") or (host.product_version or "")
|
||||
|
||||
|
||||
def host_version_status(
|
||||
host: Host,
|
||||
*,
|
||||
cfg: AgentUpdateConfig,
|
||||
latest_map: dict[str, str],
|
||||
git_versions: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
product = host.product or ""
|
||||
if git_versions is None:
|
||||
git_versions = get_git_release_versions(cfg).versions
|
||||
outdated = host_version_outdated(
|
||||
host.product_version,
|
||||
recommended=recommended_from_git(cfg, git_versions, product),
|
||||
fleet_latest=latest_map.get(product, ""),
|
||||
min_version=cfg.min_for_product(product),
|
||||
)
|
||||
if outdated is None:
|
||||
return "unknown"
|
||||
return "outdated" if outdated else "ok"
|
||||
|
||||
|
||||
def request_agent_update(db: Session, host: Host) -> Host:
|
||||
cfg = get_effective_agent_update_config(db)
|
||||
if not cfg.sac_managed:
|
||||
raise AgentUpdateNotAllowedError(
|
||||
"Agent updates from SAC are disabled (mode=gpo). Use GPO/manual update or switch to mode=sac."
|
||||
)
|
||||
if not host.agent_instance_id:
|
||||
raise AgentUpdateNotAllowedError("Host has no agent_instance_id (agent never connected via SAC)")
|
||||
|
||||
target = resolve_target_version(db, host, cfg)
|
||||
now = datetime.now(timezone.utc)
|
||||
host.pending_agent_update = True
|
||||
host.pending_update_requested_at = now
|
||||
host.pending_update_target_version = target or None
|
||||
host.agent_update_state = "requested"
|
||||
host.agent_update_last_error = None
|
||||
db.flush()
|
||||
return host
|
||||
|
||||
|
||||
def _clear_pending(host: Host) -> None:
|
||||
host.pending_agent_update = False
|
||||
host.pending_update_requested_at = None
|
||||
host.pending_update_target_version = None
|
||||
|
||||
|
||||
def process_agent_update_ingest(db: Session, host: Host, event_type: str, details: dict | None) -> None:
|
||||
if event_type not in AGENT_UPDATE_TYPES:
|
||||
return
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
host.agent_update_last_at = now
|
||||
details = details if isinstance(details, dict) else {}
|
||||
|
||||
if event_type == AGENT_UPDATE_STARTED:
|
||||
host.agent_update_state = "started"
|
||||
return
|
||||
|
||||
if event_type == AGENT_UPDATE_SUCCESS:
|
||||
host.agent_update_state = "success"
|
||||
host.agent_update_last_error = None
|
||||
_clear_pending(host)
|
||||
version = details.get("product_version") or details.get("version")
|
||||
if isinstance(version, str) and version.strip():
|
||||
host.product_version = version.strip()
|
||||
return
|
||||
|
||||
if event_type == AGENT_UPDATE_FAILED:
|
||||
host.agent_update_state = "failed"
|
||||
err = details.get("error") or details.get("message")
|
||||
host.agent_update_last_error = str(err)[:2000] if err else "agent.update.failed"
|
||||
_clear_pending(host)
|
||||
|
||||
|
||||
def run_linux_agent_update_fallback(
|
||||
host: Host,
|
||||
cfg_linux,
|
||||
*,
|
||||
repo_url: str | None = None,
|
||||
git_branch: str | None = None,
|
||||
) -> AgentUpdateFallbackResult:
|
||||
if not is_linux_host(host):
|
||||
return AgentUpdateFallbackResult(ok=False, message="Host is not Linux", product_version=None)
|
||||
if not cfg_linux.configured:
|
||||
return AgentUpdateFallbackResult(
|
||||
ok=False,
|
||||
message="Linux SSH admin is not configured",
|
||||
product_version=None,
|
||||
)
|
||||
|
||||
last = AgentUpdateFallbackResult(ok=False, message="SSH fallback failed", product_version=None)
|
||||
try:
|
||||
targets = iter_ssh_targets(host)
|
||||
except (HostNotLinuxError, HostTargetMissingError) as exc:
|
||||
return AgentUpdateFallbackResult(ok=False, message=str(exc), product_version=None)
|
||||
|
||||
effective_repo = (repo_url or "").strip() or None
|
||||
effective_branch = (git_branch or "main").strip() or "main"
|
||||
for target in targets:
|
||||
result = run_ssh_monitor_update(
|
||||
target=target,
|
||||
user=cfg_linux.user,
|
||||
password=cfg_linux.password,
|
||||
repo_url=effective_repo,
|
||||
git_branch=effective_branch,
|
||||
)
|
||||
last = AgentUpdateFallbackResult(
|
||||
ok=result.ok,
|
||||
message=result.message,
|
||||
product_version=result.agent_version,
|
||||
target=result.target,
|
||||
stdout=result.stdout,
|
||||
stderr=result.stderr,
|
||||
exit_code=result.exit_code,
|
||||
)
|
||||
if result.ok:
|
||||
return last
|
||||
return last
|
||||
|
||||
|
||||
def run_windows_agent_update_fallback(
|
||||
host: Host,
|
||||
cfg_win,
|
||||
script_path: str,
|
||||
*,
|
||||
repo_url: str = "",
|
||||
git_branch: str = "main",
|
||||
) -> AgentUpdateFallbackResult:
|
||||
if not is_windows_host(host):
|
||||
return AgentUpdateFallbackResult(ok=False, message="Host is not Windows", product_version=None)
|
||||
if not cfg_win.configured:
|
||||
return AgentUpdateFallbackResult(
|
||||
ok=False,
|
||||
message="Windows domain admin is not configured",
|
||||
product_version=None,
|
||||
)
|
||||
|
||||
try:
|
||||
targets = iter_winrm_targets(host)
|
||||
except (HostNotWindowsError, WinRmHostTargetMissingError) as exc:
|
||||
return AgentUpdateFallbackResult(ok=False, message=str(exc), product_version=None)
|
||||
|
||||
result, attempts = run_winrm_on_host_targets(
|
||||
host,
|
||||
user=cfg_win.user,
|
||||
password=cfg_win.password,
|
||||
action=lambda target: run_winrm_rdp_monitor_update(
|
||||
target=target,
|
||||
user=cfg_win.user,
|
||||
password=cfg_win.password,
|
||||
script_path=script_path,
|
||||
repo_url=repo_url,
|
||||
git_branch=git_branch,
|
||||
),
|
||||
)
|
||||
if result is None:
|
||||
return AgentUpdateFallbackResult(ok=False, message="WinRM fallback failed", product_version=None)
|
||||
message = result.message
|
||||
if attempts:
|
||||
message = f"{message} (пробовали: {', '.join(attempts)})"
|
||||
version = None
|
||||
if result.ok and result.stdout:
|
||||
from app.services.ssh_connect import parse_ssh_monitor_version_text
|
||||
|
||||
version = parse_ssh_monitor_version_text(result.stdout)
|
||||
return AgentUpdateFallbackResult(
|
||||
ok=result.ok,
|
||||
message=message,
|
||||
product_version=version,
|
||||
target=result.target,
|
||||
stdout=result.stdout,
|
||||
stderr=result.stderr,
|
||||
exit_code=result.exit_code,
|
||||
)
|
||||
|
||||
|
||||
def execute_agent_update_fallback(db: Session, host: Host) -> AgentUpdateFallbackResult:
|
||||
cfg = get_effective_agent_update_config(db)
|
||||
product = (host.product or "").strip()
|
||||
|
||||
if product == PRODUCT_SSH or is_linux_host(host):
|
||||
linux_cfg = get_effective_linux_admin_for_host(db, host)
|
||||
agent_cfg = get_effective_agent_update_config(db)
|
||||
repo_url = (agent_cfg.ssh_git_repo_url or "").strip() or None
|
||||
branch = (agent_cfg.git_branch or "main").strip() or "main"
|
||||
result = run_linux_agent_update_fallback(
|
||||
host,
|
||||
linux_cfg,
|
||||
repo_url=repo_url,
|
||||
git_branch=branch,
|
||||
)
|
||||
elif product == PRODUCT_RDP or is_windows_host(host):
|
||||
win_cfg = get_effective_win_admin_for_host(db, host)
|
||||
result = run_windows_agent_update_fallback(
|
||||
host,
|
||||
win_cfg,
|
||||
cfg.win_agent_update_script,
|
||||
repo_url=cfg.rdp_git_repo_url,
|
||||
git_branch=cfg.git_branch,
|
||||
)
|
||||
else:
|
||||
return AgentUpdateFallbackResult(
|
||||
ok=False,
|
||||
message=f"Unsupported product for fallback update: {product}",
|
||||
product_version=None,
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
host.agent_update_last_at = now
|
||||
if result.ok:
|
||||
host.agent_update_state = "success"
|
||||
host.agent_update_last_error = None
|
||||
_clear_pending(host)
|
||||
if result.product_version:
|
||||
host.product_version = result.product_version
|
||||
if is_linux_host(host):
|
||||
host.ssh_admin_ok = True
|
||||
host.ssh_admin_checked_at = now
|
||||
else:
|
||||
host.agent_update_state = "failed"
|
||||
host.agent_update_last_error = result.message[:2000]
|
||||
|
||||
db.flush()
|
||||
return result
|
||||
|
||||
|
||||
def process_agent_update_fallbacks(db: Session) -> list[dict]:
|
||||
"""Find stale pending updates and run SSH/WinRM fallback when enabled."""
|
||||
cfg = get_effective_agent_update_config(db)
|
||||
if not cfg.sac_managed or not cfg.fallback_enabled:
|
||||
return []
|
||||
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(minutes=cfg.fallback_after_minutes)
|
||||
rows = db.scalars(
|
||||
select(Host).where(
|
||||
Host.pending_agent_update.is_(True),
|
||||
Host.pending_update_requested_at.isnot(None),
|
||||
Host.pending_update_requested_at <= cutoff,
|
||||
Host.agent_update_state.in_(("requested", "started")),
|
||||
)
|
||||
).all()
|
||||
|
||||
results: list[dict] = []
|
||||
for host in rows:
|
||||
result = execute_agent_update_fallback(db, host)
|
||||
results.append(
|
||||
{
|
||||
"host_id": host.id,
|
||||
"hostname": host.hostname,
|
||||
"ok": result.ok,
|
||||
"message": result.message,
|
||||
"product_version": result.product_version,
|
||||
}
|
||||
)
|
||||
return results
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Global agent update policy (DB overrides env)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
|
||||
|
||||
PRODUCT_RDP = "rdp-login-monitor"
|
||||
PRODUCT_SSH = "ssh-monitor"
|
||||
AGENT_UPDATE_MODES = frozenset({"gpo", "sac"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentUpdateConfig:
|
||||
mode: str
|
||||
fallback_enabled: bool
|
||||
fallback_after_minutes: int
|
||||
recommended_rdp_version: str
|
||||
recommended_ssh_version: str
|
||||
min_rdp_version: str
|
||||
min_ssh_version: str
|
||||
win_agent_update_script: str
|
||||
rdp_git_repo_url: str
|
||||
ssh_git_repo_url: str
|
||||
git_branch: str
|
||||
source: str # env | db
|
||||
|
||||
@property
|
||||
def sac_managed(self) -> bool:
|
||||
return self.mode == "sac"
|
||||
|
||||
def recommended_for_product(self, product: str) -> str:
|
||||
if product == PRODUCT_RDP:
|
||||
return self.recommended_rdp_version
|
||||
if product == PRODUCT_SSH:
|
||||
return self.recommended_ssh_version
|
||||
return ""
|
||||
|
||||
def min_for_product(self, product: str) -> str:
|
||||
if product == PRODUCT_RDP:
|
||||
return self.min_rdp_version
|
||||
if product == PRODUCT_SSH:
|
||||
return self.min_ssh_version
|
||||
return ""
|
||||
|
||||
|
||||
def _agent_update_from_env() -> AgentUpdateConfig:
|
||||
settings = get_settings()
|
||||
mode = (settings.sac_agent_update_mode or "gpo").strip().lower()
|
||||
if mode not in AGENT_UPDATE_MODES:
|
||||
mode = "gpo"
|
||||
return AgentUpdateConfig(
|
||||
mode=mode,
|
||||
fallback_enabled=settings.sac_agent_update_fallback_enabled,
|
||||
fallback_after_minutes=max(1, int(settings.sac_agent_update_fallback_minutes)),
|
||||
recommended_rdp_version=(settings.sac_agent_recommended_rdp_version or "").strip(),
|
||||
recommended_ssh_version=(settings.sac_agent_recommended_ssh_version or "").strip(),
|
||||
min_rdp_version=(settings.sac_agent_min_rdp_version or "").strip(),
|
||||
min_ssh_version=(settings.sac_agent_min_ssh_version or "").strip(),
|
||||
win_agent_update_script=(settings.sac_win_agent_update_script or "").strip(),
|
||||
rdp_git_repo_url=(settings.sac_agent_rdp_git_repo_url or "").strip(),
|
||||
ssh_git_repo_url=(settings.sac_agent_ssh_git_repo_url or "").strip(),
|
||||
git_branch=(settings.sac_agent_git_branch or "main").strip() or "main",
|
||||
source="env",
|
||||
)
|
||||
|
||||
|
||||
def _row_has_agent_update_values(row: UiSettings) -> bool:
|
||||
return any(
|
||||
[
|
||||
(row.agent_update_mode or "").strip() not in ("", "gpo"),
|
||||
row.agent_update_fallback_enabled is not None,
|
||||
row.agent_update_fallback_minutes not in (None, 15),
|
||||
bool((row.agent_recommended_rdp_version or "").strip()),
|
||||
bool((row.agent_recommended_ssh_version or "").strip()),
|
||||
bool((row.agent_min_rdp_version or "").strip()),
|
||||
bool((row.agent_min_ssh_version or "").strip()),
|
||||
bool((row.win_agent_update_script or "").strip()),
|
||||
bool((row.agent_rdp_git_repo_url or "").strip()),
|
||||
bool((row.agent_ssh_git_repo_url or "").strip()),
|
||||
(row.agent_git_branch or "main").strip() not in ("", "main"),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def get_effective_agent_update_config(db: Session | None = None) -> AgentUpdateConfig:
|
||||
if db is None:
|
||||
from app.database import SessionLocal
|
||||
|
||||
session = SessionLocal()
|
||||
try:
|
||||
return get_effective_agent_update_config(session)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
|
||||
env_cfg = _agent_update_from_env()
|
||||
if row is None:
|
||||
return env_cfg
|
||||
|
||||
mode = (row.agent_update_mode or env_cfg.mode).strip().lower()
|
||||
if mode not in AGENT_UPDATE_MODES:
|
||||
mode = env_cfg.mode
|
||||
|
||||
return AgentUpdateConfig(
|
||||
mode=mode,
|
||||
fallback_enabled=(
|
||||
row.agent_update_fallback_enabled
|
||||
if row.agent_update_fallback_enabled is not None
|
||||
else env_cfg.fallback_enabled
|
||||
),
|
||||
fallback_after_minutes=max(
|
||||
1,
|
||||
int(row.agent_update_fallback_minutes or env_cfg.fallback_after_minutes),
|
||||
),
|
||||
recommended_rdp_version=(row.agent_recommended_rdp_version or "").strip()
|
||||
or env_cfg.recommended_rdp_version,
|
||||
recommended_ssh_version=(row.agent_recommended_ssh_version or "").strip()
|
||||
or env_cfg.recommended_ssh_version,
|
||||
min_rdp_version=(row.agent_min_rdp_version or "").strip() or env_cfg.min_rdp_version,
|
||||
min_ssh_version=(row.agent_min_ssh_version or "").strip() or env_cfg.min_ssh_version,
|
||||
win_agent_update_script=(row.win_agent_update_script or "").strip()
|
||||
or env_cfg.win_agent_update_script,
|
||||
rdp_git_repo_url=(row.agent_rdp_git_repo_url or "").strip() or env_cfg.rdp_git_repo_url,
|
||||
ssh_git_repo_url=(row.agent_ssh_git_repo_url or "").strip() or env_cfg.ssh_git_repo_url,
|
||||
git_branch=(row.agent_git_branch or env_cfg.git_branch).strip() or env_cfg.git_branch,
|
||||
source="db" if _row_has_agent_update_values(row) else env_cfg.source,
|
||||
)
|
||||
|
||||
|
||||
def upsert_agent_update_settings(
|
||||
db: Session,
|
||||
*,
|
||||
mode: str | None = None,
|
||||
fallback_enabled: bool | None = None,
|
||||
fallback_after_minutes: int | None = None,
|
||||
recommended_rdp_version: str | None = None,
|
||||
recommended_ssh_version: str | None = None,
|
||||
min_rdp_version: str | None = None,
|
||||
min_ssh_version: str | None = None,
|
||||
win_agent_update_script: str | None = None,
|
||||
rdp_git_repo_url: str | None = None,
|
||||
ssh_git_repo_url: str | None = None,
|
||||
git_branch: str | None = None,
|
||||
) -> AgentUpdateConfig:
|
||||
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
|
||||
if row is None:
|
||||
row = UiSettings(id=UI_SETTINGS_ROW_ID, show_sidebar_system_stats=True)
|
||||
db.add(row)
|
||||
|
||||
if mode is not None:
|
||||
normalized = mode.strip().lower()
|
||||
if normalized not in AGENT_UPDATE_MODES:
|
||||
raise ValueError(f"mode must be one of: {sorted(AGENT_UPDATE_MODES)}")
|
||||
row.agent_update_mode = normalized
|
||||
if fallback_enabled is not None:
|
||||
row.agent_update_fallback_enabled = fallback_enabled
|
||||
if fallback_after_minutes is not None:
|
||||
row.agent_update_fallback_minutes = max(1, int(fallback_after_minutes))
|
||||
if recommended_rdp_version is not None:
|
||||
row.agent_recommended_rdp_version = recommended_rdp_version.strip() or None
|
||||
if recommended_ssh_version is not None:
|
||||
row.agent_recommended_ssh_version = recommended_ssh_version.strip() or None
|
||||
if min_rdp_version is not None:
|
||||
row.agent_min_rdp_version = min_rdp_version.strip() or None
|
||||
if min_ssh_version is not None:
|
||||
row.agent_min_ssh_version = min_ssh_version.strip() or None
|
||||
if win_agent_update_script is not None:
|
||||
row.win_agent_update_script = win_agent_update_script.strip() or None
|
||||
if rdp_git_repo_url is not None:
|
||||
row.agent_rdp_git_repo_url = rdp_git_repo_url.strip() or None
|
||||
row.agent_git_rdp_version = None
|
||||
row.agent_git_fetched_at = None
|
||||
if ssh_git_repo_url is not None:
|
||||
row.agent_ssh_git_repo_url = ssh_git_repo_url.strip() or None
|
||||
row.agent_git_ssh_version = None
|
||||
row.agent_git_fetched_at = None
|
||||
if git_branch is not None:
|
||||
branch = git_branch.strip() or "main"
|
||||
if branch != (row.agent_git_branch or "main"):
|
||||
row.agent_git_rdp_version = None
|
||||
row.agent_git_ssh_version = None
|
||||
row.agent_git_fetched_at = None
|
||||
row.agent_git_branch = branch
|
||||
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return get_effective_agent_update_config(db)
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Agent update fallback result with remote command streams."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentUpdateFallbackResult:
|
||||
ok: bool
|
||||
message: str
|
||||
product_version: str | None
|
||||
target: str = ""
|
||||
stdout: str = ""
|
||||
stderr: str = ""
|
||||
exit_code: int | None = None
|
||||
@@ -60,6 +60,75 @@ def is_agent_version_outdated(
|
||||
return lag >= lag_threshold
|
||||
|
||||
|
||||
def effective_reference_version(
|
||||
*,
|
||||
recommended: str = "",
|
||||
fleet_latest: str = "",
|
||||
min_version: str = "",
|
||||
) -> str | None:
|
||||
"""Единый эталон: max(recommended, min_version, max версия в fleet по продукту)."""
|
||||
best: ParsedAgentVersion | None = None
|
||||
best_raw: str | None = None
|
||||
for raw in (recommended, min_version, fleet_latest):
|
||||
text = (raw or "").strip()
|
||||
parsed = parse_agent_version(text)
|
||||
if parsed is None:
|
||||
continue
|
||||
if best is None or parsed > best:
|
||||
best = parsed
|
||||
best_raw = text
|
||||
return best_raw
|
||||
|
||||
|
||||
def reference_agent_versions_by_product(
|
||||
cfg,
|
||||
latest_map: dict[str, str],
|
||||
*,
|
||||
git_versions: dict[str, str] | None = None,
|
||||
products: tuple[str, ...] = ("rdp-login-monitor", "ssh-monitor"),
|
||||
) -> dict[str, str]:
|
||||
from app.services.agent_git_release import recommended_from_git
|
||||
from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH
|
||||
|
||||
git_versions = git_versions or {}
|
||||
product_cfg = {
|
||||
PRODUCT_RDP: (recommended_from_git(cfg, git_versions, PRODUCT_RDP), cfg.min_for_product(PRODUCT_RDP)),
|
||||
PRODUCT_SSH: (recommended_from_git(cfg, git_versions, PRODUCT_SSH), cfg.min_for_product(PRODUCT_SSH)),
|
||||
}
|
||||
out: dict[str, str] = {}
|
||||
for product in products:
|
||||
recommended, min_version = product_cfg.get(product, ("", ""))
|
||||
ref = effective_reference_version(
|
||||
recommended=recommended,
|
||||
fleet_latest=latest_map.get(product, ""),
|
||||
min_version=min_version,
|
||||
)
|
||||
if ref:
|
||||
out[product] = ref
|
||||
return out
|
||||
|
||||
|
||||
def host_version_outdated(
|
||||
host_version: str | None,
|
||||
*,
|
||||
recommended: str = "",
|
||||
fleet_latest: str = "",
|
||||
min_version: str = "",
|
||||
lag_threshold: int = _DEFAULT_LAG_THRESHOLD,
|
||||
) -> bool | None:
|
||||
"""True=outdated, False=ok, None=unknown (нет эталона или непарсится версия хоста)."""
|
||||
if not host_version or parse_agent_version(host_version) is None:
|
||||
return None
|
||||
reference = effective_reference_version(
|
||||
recommended=recommended,
|
||||
fleet_latest=fleet_latest,
|
||||
min_version=min_version,
|
||||
)
|
||||
if not reference:
|
||||
return None
|
||||
return is_agent_version_outdated(host_version, reference, lag_threshold=lag_threshold)
|
||||
|
||||
|
||||
def latest_agent_versions_by_product(db: Session) -> dict[str, str]:
|
||||
rows = db.execute(
|
||||
select(Host.product, Host.product_version).where(
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Resolve client IP behind reverse proxy (nginx $proxy_add_x_forwarded_for)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
|
||||
def client_ip_from_request(request: Request) -> str:
|
||||
"""Client IP for rate limiting.
|
||||
|
||||
nginx with ``proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for``
|
||||
appends the real peer address as the *last* hop. Spoofed values in the
|
||||
incoming header therefore cannot replace the actual client IP.
|
||||
"""
|
||||
if request.client and request.client.host:
|
||||
direct = request.client.host.strip()
|
||||
else:
|
||||
direct = "unknown"
|
||||
|
||||
forwarded = (request.headers.get("x-forwarded-for") or "").strip()
|
||||
if not forwarded:
|
||||
return direct[:64]
|
||||
|
||||
parts = [part.strip() for part in forwarded.split(",") if part.strip()]
|
||||
if not parts:
|
||||
return direct[:64]
|
||||
return parts[-1][:64]
|
||||
@@ -16,6 +16,10 @@ SSH_BAN_TYPES = frozenset({"ssh.ip.banned"})
|
||||
|
||||
_SERVER_LINE_RE = re.compile(r"(?m)^🖥️\s*Сервер\s*:")
|
||||
_ACTIVE_USERS_HEADER_RE = re.compile(r"^👥\s*АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ")
|
||||
_ACTIVE_USERS_EMPTY_LINE_RE = re.compile(
|
||||
r"^\(?нет данных|нет активных пользователей",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_AGENT_VERSION_LINE_RE = re.compile(r"(?m)^Agent version\s+", re.IGNORECASE)
|
||||
_NOTIFICATION_SOURCE_RE = re.compile(r"(?m)^📡\s*Оповещение:\s*")
|
||||
_LEGACY_SAC_SOURCE_RE = re.compile(
|
||||
@@ -130,10 +134,21 @@ def collapse_blank_lines(text: str, *, max_run: int = 1) -> str:
|
||||
return "\n".join(out).strip()
|
||||
|
||||
|
||||
def is_active_users_empty_line(text: str) -> bool:
|
||||
"""Строка-заглушка вместо списка сессий (агент или SAC)."""
|
||||
s = text.strip()
|
||||
if not s:
|
||||
return True
|
||||
inner = s.lstrip("👤").strip()
|
||||
if _ACTIVE_USERS_EMPTY_LINE_RE.search(inner):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def split_active_user_tokens(entry: str) -> list[str]:
|
||||
"""Разбивает «👤 u1 👤 u2» на отдельные строки (как в отчёте агента)."""
|
||||
entry = entry.strip()
|
||||
if not entry:
|
||||
if not entry or is_active_users_empty_line(entry):
|
||||
return []
|
||||
if entry.count("👤") <= 1:
|
||||
line = entry if entry.startswith("👤") else f"👤 {entry}"
|
||||
@@ -231,21 +246,71 @@ def normalize_active_users_in_body(body: str) -> str:
|
||||
break
|
||||
if _SECTION_HEADER_RE.match(stripped) and not stripped.startswith("👤"):
|
||||
break
|
||||
if stripped.startswith("(нет данных"):
|
||||
if is_active_users_empty_line(stripped):
|
||||
out[-1] = _fix_active_users_header_count(out[-1], 0)
|
||||
out.append(cur)
|
||||
i += 1
|
||||
break
|
||||
user_lines.extend(split_active_user_tokens(cur))
|
||||
i += 1
|
||||
user_lines = [ln for ln in user_lines if not is_active_users_empty_line(ln.strip())]
|
||||
if user_lines:
|
||||
out[-1] = _fix_active_users_header_count(out[-1], len(user_lines))
|
||||
out.extend(user_lines)
|
||||
else:
|
||||
out[-1] = _fix_active_users_header_count(out[-1], 0)
|
||||
continue
|
||||
out.append(line)
|
||||
i += 1
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def extract_active_users_from_report_body(body: str) -> list[str]:
|
||||
lines = body.replace("\r\n", "\n").split("\n")
|
||||
in_section = False
|
||||
users: list[str] = []
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if _ACTIVE_USERS_HEADER_RE.match(stripped):
|
||||
in_section = True
|
||||
continue
|
||||
if not in_section:
|
||||
continue
|
||||
if not stripped:
|
||||
break
|
||||
if is_active_users_empty_line(stripped):
|
||||
break
|
||||
if stripped.startswith("Источник:") or stripped.startswith(NOTIFICATION_SOURCE_PREFIX):
|
||||
break
|
||||
if _SECTION_HEADER_RE.match(stripped) and not stripped.startswith("👤"):
|
||||
break
|
||||
users.extend(split_active_user_tokens(stripped))
|
||||
return normalize_active_users_list(users)
|
||||
|
||||
|
||||
def reconcile_stats_from_report_body(
|
||||
stats: dict[str, Any],
|
||||
body: str,
|
||||
platform: Platform,
|
||||
) -> dict[str, Any]:
|
||||
"""Синхронизирует stats.active_users и счётчики с текстом отчёта после нормализации."""
|
||||
out = dict(stats)
|
||||
users = extract_active_users_from_report_body(body)
|
||||
out["active_users"] = users
|
||||
count = len(users)
|
||||
if platform == "ssh":
|
||||
out["active_sessions"] = count
|
||||
else:
|
||||
out["active_sessions_rdp"] = count
|
||||
names: list[str] = []
|
||||
for raw in users:
|
||||
name = re.sub(r"^👤\s*", "", raw.strip()).strip()
|
||||
if name and not is_active_users_empty_line(name):
|
||||
names.append(name)
|
||||
out["unique_users"] = sorted(set(names))
|
||||
return out
|
||||
|
||||
|
||||
def normalize_report_body(body: str, host: Host | None, platform: Platform) -> str:
|
||||
"""Приводит текст отчёта (агент/SAC) к единому компактному виду."""
|
||||
text = collapse_blank_lines(body.replace("\r\n", "\n"))
|
||||
@@ -422,5 +487,6 @@ def normalize_daily_report_details(
|
||||
stats = out.get("stats")
|
||||
if isinstance(stats, dict):
|
||||
stats = enrich_stats_for_storage(platform, dict(stats))
|
||||
stats = reconcile_stats_from_report_body(stats, normalized_body, platform)
|
||||
out["stats"] = stats
|
||||
return out
|
||||
|
||||
@@ -15,6 +15,7 @@ ACTOR_USER_EVENT_TYPES: frozenset[str] = frozenset(
|
||||
"session.logind.failed",
|
||||
"rdp.login.success",
|
||||
"rdp.login.failed",
|
||||
"rdp.session.logoff",
|
||||
"rdp.shadow.control.started",
|
||||
"rdp.shadow.control.stopped",
|
||||
"rdp.shadow.control.permission",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Per event-type severity overrides (admin Settings UI)."""
|
||||
"""Per event-type severity overrides (admin Settings UI)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.constants.event_types import DEFAULT_EVENT_SEVERITIES
|
||||
from app.models.event_severity_override import EventSeverityOverride
|
||||
from app.services.event_type_visibility import get_visibility_map
|
||||
from app.services.notification_settings import VALID_SEVERITIES
|
||||
|
||||
SOURCE_DB = "db"
|
||||
@@ -19,6 +20,7 @@ class EventSeverityOverrideItem:
|
||||
event_type: str
|
||||
default_severity: str
|
||||
override_severity: str | None
|
||||
show_in_events: bool = True
|
||||
|
||||
|
||||
def get_override_map(db: Session) -> dict[str, str]:
|
||||
@@ -48,7 +50,8 @@ def apply_severity_override(payload: dict, db: Session) -> dict:
|
||||
|
||||
def list_severity_override_items(db: Session) -> list[EventSeverityOverrideItem]:
|
||||
override_map = get_override_map(db)
|
||||
types = set(DEFAULT_EVENT_SEVERITIES) | set(override_map)
|
||||
visibility_map = get_visibility_map(db)
|
||||
types = set(DEFAULT_EVENT_SEVERITIES) | set(override_map) | set(visibility_map)
|
||||
items: list[EventSeverityOverrideItem] = []
|
||||
for event_type in sorted(types):
|
||||
default = DEFAULT_EVENT_SEVERITIES.get(event_type, "info")
|
||||
@@ -57,6 +60,7 @@ def list_severity_override_items(db: Session) -> list[EventSeverityOverrideItem]
|
||||
event_type=event_type,
|
||||
default_severity=default,
|
||||
override_severity=override_map.get(event_type),
|
||||
show_in_events=visibility_map.get(event_type, True),
|
||||
)
|
||||
)
|
||||
return items
|
||||
|
||||
@@ -1,10 +1,35 @@
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.event import Event
|
||||
from app.schemas.list_models import EventSummary
|
||||
from app.services.event_actor_user import extract_event_actor_user
|
||||
from app.services.host_sessions import event_session_terminated
|
||||
from app.services.rdg_display import build_rdg_display
|
||||
from app.services.rdg_session_flap import resolve_rdg_flap_summary
|
||||
from app.services.session_duration import extract_session_duration_sec
|
||||
|
||||
|
||||
def event_to_summary(event: Event) -> EventSummary:
|
||||
def event_to_summary(event: Event, db: Session | None = None) -> EventSummary:
|
||||
host = event.host
|
||||
rdg_flap = False
|
||||
rdg_flap_pair_event_id: int | None = None
|
||||
rdg_flap_qwinsta_event_id: int | None = None
|
||||
if db is not None:
|
||||
rdg_flap, rdg_flap_pair_event_id, rdg_flap_qwinsta_event_id = resolve_rdg_flap_summary(
|
||||
db, event
|
||||
)
|
||||
|
||||
title = event.title
|
||||
summary = event.summary
|
||||
rdg_access_path: str | None = None
|
||||
rdg_qwinsta_enabled = False
|
||||
rdg_display = build_rdg_display(event, db)
|
||||
if rdg_display is not None:
|
||||
title = rdg_display.title
|
||||
summary = rdg_display.summary
|
||||
rdg_access_path = rdg_display.access_path
|
||||
rdg_qwinsta_enabled = rdg_display.qwinsta_enabled
|
||||
|
||||
return EventSummary(
|
||||
id=event.id,
|
||||
event_id=event.event_id,
|
||||
@@ -17,7 +42,16 @@ def event_to_summary(event: Event) -> EventSummary:
|
||||
category=event.category,
|
||||
type=event.type,
|
||||
severity=event.severity,
|
||||
title=event.title,
|
||||
summary=event.summary,
|
||||
title=title,
|
||||
summary=summary,
|
||||
actor_user=extract_event_actor_user(event.type, event.details),
|
||||
session_duration_sec=extract_session_duration_sec(
|
||||
event.details if isinstance(event.details, dict) else None
|
||||
),
|
||||
rdg_flap=rdg_flap,
|
||||
rdg_flap_pair_event_id=rdg_flap_pair_event_id,
|
||||
rdg_flap_qwinsta_event_id=rdg_flap_qwinsta_event_id,
|
||||
rdg_access_path=rdg_access_path,
|
||||
rdg_qwinsta_enabled=rdg_qwinsta_enabled,
|
||||
session_terminated=event_session_terminated(event, db=db),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Hide selected event types from SAC UI/API event lists."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.sql import ColumnElement
|
||||
|
||||
from app.models.event import Event
|
||||
from app.models.event_type_visibility import EventTypeVisibility
|
||||
|
||||
|
||||
def get_hidden_event_types(db: Session) -> frozenset[str]:
|
||||
rows = db.scalars(
|
||||
select(EventTypeVisibility.event_type).where(EventTypeVisibility.show_in_events.is_(False))
|
||||
).all()
|
||||
return frozenset(rows)
|
||||
|
||||
|
||||
def get_visibility_map(db: Session) -> dict[str, bool]:
|
||||
rows = db.scalars(select(EventTypeVisibility)).all()
|
||||
return {row.event_type: row.show_in_events for row in rows}
|
||||
|
||||
|
||||
def show_in_events_for(event_type: str, *, hidden: frozenset[str]) -> bool:
|
||||
return event_type not in hidden
|
||||
|
||||
|
||||
def event_type_notifications_enabled(event_type: str, db: Session | None) -> bool:
|
||||
"""Outbound alerts (Telegram/push/email/webhook) only for types visible in events UI."""
|
||||
if db is None:
|
||||
return True
|
||||
hidden = get_hidden_event_types(db)
|
||||
return show_in_events_for(event_type, hidden=hidden)
|
||||
|
||||
|
||||
def visibility_type_filter(hidden: frozenset[str]) -> ColumnElement[bool] | None:
|
||||
if not hidden:
|
||||
return None
|
||||
return Event.type.not_in(tuple(sorted(hidden)))
|
||||
|
||||
|
||||
def replace_event_visibility(db: Session, visibility: dict[str, bool | None]) -> dict[str, bool]:
|
||||
stored = get_visibility_map(db)
|
||||
for event_type, show in visibility.items():
|
||||
normalized_type = event_type.strip()
|
||||
if not normalized_type:
|
||||
raise ValueError("event_type is required")
|
||||
if show is None or show is True:
|
||||
db.execute(
|
||||
delete(EventTypeVisibility).where(EventTypeVisibility.event_type == normalized_type)
|
||||
)
|
||||
stored.pop(normalized_type, None)
|
||||
continue
|
||||
if show is False:
|
||||
row = db.get(EventTypeVisibility, normalized_type)
|
||||
if row is None:
|
||||
row = EventTypeVisibility(event_type=normalized_type, show_in_events=False)
|
||||
db.add(row)
|
||||
else:
|
||||
row.show_in_events = False
|
||||
stored[normalized_type] = False
|
||||
else:
|
||||
raise ValueError("show_in_events must be true, false, or null")
|
||||
db.flush()
|
||||
return stored
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Best-effort integration with fail2ban for SSH login bans (OS level, not SAC DB)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_BANNED_IP_RE = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Fail2banActionResult:
|
||||
ok: bool
|
||||
message: str
|
||||
|
||||
|
||||
def _ssh_jail() -> str:
|
||||
settings = get_settings()
|
||||
jail = (getattr(settings, "sac_fail2ban_ssh_jail", None) or "sshd").strip()
|
||||
return jail or "sshd"
|
||||
|
||||
|
||||
def _run_fail2ban(args: list[str], *, timeout: int = 15) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
["fail2ban-client", *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def fail2ban_available() -> bool:
|
||||
try:
|
||||
proc = _run_fail2ban(["ping"], timeout=5)
|
||||
return proc.returncode == 0
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return False
|
||||
|
||||
|
||||
def list_ssh_banned_ips() -> list[str]:
|
||||
if not fail2ban_available():
|
||||
return []
|
||||
jail = _ssh_jail()
|
||||
try:
|
||||
proc = _run_fail2ban(["status", jail])
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
logger.warning("fail2ban status failed: %s", exc)
|
||||
return []
|
||||
if proc.returncode != 0:
|
||||
return []
|
||||
text = (proc.stdout or "") + "\n" + (proc.stderr or "")
|
||||
ips: list[str] = []
|
||||
capture = False
|
||||
for line in text.splitlines():
|
||||
lower = line.strip().lower()
|
||||
if "banned ip list" in lower:
|
||||
capture = True
|
||||
tail = line.split(":", 1)[-1]
|
||||
ips.extend(_BANNED_IP_RE.findall(tail))
|
||||
continue
|
||||
if capture:
|
||||
if not line.strip():
|
||||
break
|
||||
ips.extend(_BANNED_IP_RE.findall(line))
|
||||
# preserve order, unique
|
||||
seen: set[str] = set()
|
||||
out: list[str] = []
|
||||
for ip in ips:
|
||||
if ip not in seen:
|
||||
seen.add(ip)
|
||||
out.append(ip)
|
||||
return out
|
||||
|
||||
|
||||
def unban_ssh_ip(ip_address: str) -> Fail2banActionResult:
|
||||
ip = (ip_address or "").strip()
|
||||
if not ip:
|
||||
return Fail2banActionResult(ok=False, message="empty ip")
|
||||
if not fail2ban_available():
|
||||
return Fail2banActionResult(ok=False, message="fail2ban-client недоступен на сервере SAC")
|
||||
jail = _ssh_jail()
|
||||
try:
|
||||
proc = _run_fail2ban(["set", jail, "unbanip", ip])
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
return Fail2banActionResult(ok=False, message=str(exc))
|
||||
if proc.returncode != 0:
|
||||
err = (proc.stderr or proc.stdout or "unban failed").strip()
|
||||
return Fail2banActionResult(ok=False, message=err[:500])
|
||||
return Fail2banActionResult(ok=True, message=f"SSH: IP {ip} разблокирован (jail {jail})")
|
||||
|
||||
|
||||
def sync_fail2ban_ignore_ips(ip_addresses: list[str]) -> Fail2banActionResult:
|
||||
settings = get_settings()
|
||||
if not getattr(settings, "sac_fail2ban_sync_enabled", False):
|
||||
return Fail2banActionResult(ok=True, message="sync отключён (SAC_FAIL2BAN_SYNC_ENABLED=false)")
|
||||
path = (getattr(settings, "sac_fail2ban_ignoreip_file", None) or "").strip()
|
||||
if not path:
|
||||
return Fail2banActionResult(ok=True, message="файл ignoreip не задан (SAC_FAIL2BAN_IGNOREIP_FILE)")
|
||||
|
||||
jail = _ssh_jail()
|
||||
base_ignore = "127.0.0.1/8 ::1"
|
||||
extra = " ".join(ip for ip in ip_addresses if ip)
|
||||
ignore_line = f"{base_ignore} {extra}".strip()
|
||||
content = (
|
||||
f"# Managed by SAC — login IP whitelist for fail2ban\n"
|
||||
f"[{jail}]\n"
|
||||
f"ignoreip = {ignore_line}\n"
|
||||
)
|
||||
try:
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
fh.write(content)
|
||||
except OSError as exc:
|
||||
return Fail2banActionResult(ok=False, message=f"не удалось записать {path}: {exc}")
|
||||
|
||||
if not fail2ban_available():
|
||||
return Fail2banActionResult(ok=True, message=f"файл записан ({path}); fail2ban reload пропущен")
|
||||
|
||||
try:
|
||||
proc = _run_fail2ban(["reload"], timeout=30)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
return Fail2banActionResult(ok=False, message=f"файл записан, reload failed: {exc}")
|
||||
if proc.returncode != 0:
|
||||
err = (proc.stderr or proc.stdout or "reload failed").strip()
|
||||
return Fail2banActionResult(ok=False, message=f"файл записан, reload: {err[:300]}")
|
||||
return Fail2banActionResult(ok=True, message=f"fail2ban ignoreip обновлён ({path})")
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Probe remote host and register in SAC before agent deploy (WinRM / SSH)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Host
|
||||
from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH
|
||||
from app.services.linux_admin_settings import get_effective_linux_admin_config
|
||||
from app.services.ssh_connect import _is_ipv4, _ssh_output_hostname, test_ssh_connection
|
||||
from app.services.win_admin_settings import get_effective_win_admin_config
|
||||
from app.services.winrm_connect import test_winrm_connection
|
||||
|
||||
_IPV4_RE = re.compile(r"^\d{1,3}(?:\.\d{1,3}){3}$")
|
||||
|
||||
|
||||
class ManualHostAddError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def validate_windows_target(target: str) -> str:
|
||||
text = (target or "").strip()
|
||||
if not text:
|
||||
raise ManualHostAddError("Укажите имя Windows-ПК")
|
||||
if _IPV4_RE.match(text):
|
||||
raise ManualHostAddError(
|
||||
"Для Windows укажите имя ПК (NetBIOS/DNS), не IP — WinRM с Kerberos по IP ненадёжен"
|
||||
)
|
||||
short = text.split(".")[0].split("\\")[-1].strip()
|
||||
if not short or " " in short:
|
||||
raise ManualHostAddError("Некорректное имя хоста")
|
||||
return short
|
||||
|
||||
|
||||
def validate_linux_target(target: str) -> str:
|
||||
text = (target or "").strip()
|
||||
if not text:
|
||||
raise ManualHostAddError("Укажите IP или hostname Linux-хоста")
|
||||
return text
|
||||
|
||||
|
||||
def probe_windows_host(db: Session, hostname: str) -> str:
|
||||
cfg = get_effective_win_admin_config(db)
|
||||
if not cfg.configured:
|
||||
raise ManualHostAddError("Windows domain admin не настроен в SAC")
|
||||
probe = test_winrm_connection(
|
||||
target=hostname,
|
||||
user=cfg.user,
|
||||
password=cfg.password,
|
||||
)
|
||||
if not probe.ok:
|
||||
raise ManualHostAddError(probe.message)
|
||||
return (probe.hostname or hostname).strip()
|
||||
|
||||
|
||||
def probe_linux_host(db: Session, target: str) -> tuple[str, str | None]:
|
||||
cfg = get_effective_linux_admin_config(db)
|
||||
if not cfg.configured:
|
||||
raise ManualHostAddError("Linux SSH admin не настроен в SAC")
|
||||
probe = test_ssh_connection(
|
||||
target=target,
|
||||
user=cfg.user,
|
||||
password=cfg.password,
|
||||
)
|
||||
if not probe.ok:
|
||||
raise ManualHostAddError(probe.message)
|
||||
hostname = _ssh_output_hostname(probe.stdout) or target
|
||||
ipv4 = target if _is_ipv4(target) else None
|
||||
return hostname, ipv4
|
||||
|
||||
|
||||
def get_or_create_manual_host(
|
||||
db: Session,
|
||||
*,
|
||||
hostname: str,
|
||||
product: str,
|
||||
os_family: str,
|
||||
ipv4: str | None = None,
|
||||
) -> Host:
|
||||
host = db.scalar(
|
||||
select(Host).where(Host.hostname == hostname, Host.product == product).limit(1)
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
if host is None:
|
||||
host = Host(
|
||||
hostname=hostname,
|
||||
os_family=os_family,
|
||||
product=product,
|
||||
ipv4=ipv4,
|
||||
last_seen_at=now,
|
||||
)
|
||||
db.add(host)
|
||||
else:
|
||||
host.os_family = os_family
|
||||
if ipv4:
|
||||
host.ipv4 = ipv4
|
||||
host.last_seen_at = now
|
||||
db.flush()
|
||||
return host
|
||||
|
||||
|
||||
def prepare_manual_host_add(db: Session, *, platform: str, target: str) -> Host:
|
||||
platform_norm = (platform or "").strip().lower()
|
||||
if platform_norm == "windows":
|
||||
win_target = validate_windows_target(target)
|
||||
hostname = probe_windows_host(db, win_target)
|
||||
return get_or_create_manual_host(
|
||||
db,
|
||||
hostname=hostname,
|
||||
product=PRODUCT_RDP,
|
||||
os_family="windows",
|
||||
)
|
||||
if platform_norm == "linux":
|
||||
linux_target = validate_linux_target(target)
|
||||
hostname, ipv4 = probe_linux_host(db, linux_target)
|
||||
return get_or_create_manual_host(
|
||||
db,
|
||||
hostname=hostname,
|
||||
product=PRODUCT_SSH,
|
||||
os_family="linux",
|
||||
ipv4=ipv4,
|
||||
)
|
||||
raise ManualHostAddError("platform должен быть windows или linux")
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Per-host management credentials (override global Win/Linux admin)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.host import Host
|
||||
from app.services.linux_admin_settings import (
|
||||
LinuxAdminConfig,
|
||||
get_effective_linux_admin_for_host,
|
||||
)
|
||||
from app.services.win_admin_settings import (
|
||||
WinAdminConfig,
|
||||
get_effective_win_admin_for_host,
|
||||
normalize_win_admin_user,
|
||||
)
|
||||
|
||||
|
||||
def mask_secret(value: str | None) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
text = value.strip()
|
||||
if not text:
|
||||
return None
|
||||
if len(text) <= 4:
|
||||
return "****"
|
||||
return f"{text[:2]}…{text[-2:]}"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HostMgmtAccessView:
|
||||
host_id: int
|
||||
has_override: bool
|
||||
user: str | None
|
||||
password_set: bool
|
||||
password_hint: str | None
|
||||
effective_source: str
|
||||
effective_configured: bool
|
||||
effective_user: str | None
|
||||
|
||||
|
||||
def _effective_for_host(db: Session, host: Host) -> WinAdminConfig | LinuxAdminConfig:
|
||||
os_family = (host.os_family or "").strip().lower()
|
||||
if os_family == "windows" or (host.product or "").strip().lower() in {"rdp-login-monitor", "rdp"}:
|
||||
return get_effective_win_admin_for_host(db, host)
|
||||
if os_family == "linux" or (host.product or "").strip().lower() in {"ssh-monitor", "ssh"}:
|
||||
return get_effective_linux_admin_for_host(db, host)
|
||||
# Fallback: try host override first via win helper (same columns), then global win, then linux.
|
||||
win_cfg = get_effective_win_admin_for_host(db, host)
|
||||
if win_cfg.configured:
|
||||
return win_cfg
|
||||
return get_effective_linux_admin_for_host(db, host)
|
||||
|
||||
|
||||
def get_host_mgmt_access_view(db: Session, host: Host) -> HostMgmtAccessView:
|
||||
host_user = (host.mgmt_user or "").strip() or None
|
||||
host_password = (host.mgmt_password or "").strip() or None
|
||||
has_override = bool(host_user or host_password)
|
||||
effective = _effective_for_host(db, host)
|
||||
return HostMgmtAccessView(
|
||||
host_id=host.id,
|
||||
has_override=has_override,
|
||||
user=host_user,
|
||||
password_set=bool(host_password),
|
||||
password_hint=mask_secret(host_password),
|
||||
effective_source=effective.source,
|
||||
effective_configured=effective.configured,
|
||||
effective_user=effective.user or None,
|
||||
)
|
||||
|
||||
|
||||
def upsert_host_mgmt_credentials(
|
||||
db: Session,
|
||||
host: Host,
|
||||
*,
|
||||
user: str | None = None,
|
||||
password: str | None = None,
|
||||
clear: bool = False,
|
||||
) -> HostMgmtAccessView:
|
||||
"""Update per-host credentials.
|
||||
|
||||
- clear=True → wipe host override (use global Settings).
|
||||
- user="" → clear username.
|
||||
- password omitted (None) → keep existing password.
|
||||
- password="" → clear password.
|
||||
"""
|
||||
if clear:
|
||||
host.mgmt_user = None
|
||||
host.mgmt_password = None
|
||||
db.commit()
|
||||
db.refresh(host)
|
||||
return get_host_mgmt_access_view(db, host)
|
||||
|
||||
if user is not None:
|
||||
cleaned = user.strip()
|
||||
if not cleaned:
|
||||
host.mgmt_user = None
|
||||
else:
|
||||
# Preserve DOMAIN\\user form for Windows; for Linux leave as-is after strip.
|
||||
os_family = (host.os_family or "").strip().lower()
|
||||
if os_family == "windows" or "\\" in cleaned or "@" in cleaned:
|
||||
host.mgmt_user = normalize_win_admin_user(cleaned)
|
||||
else:
|
||||
host.mgmt_user = cleaned
|
||||
|
||||
if password is not None:
|
||||
if not password.strip():
|
||||
host.mgmt_password = None
|
||||
else:
|
||||
host.mgmt_password = password
|
||||
|
||||
db.commit()
|
||||
db.refresh(host)
|
||||
return get_host_mgmt_access_view(db, host)
|
||||
@@ -0,0 +1,449 @@
|
||||
"""Background SSH/WinRM host actions (survives UI navigation and multi-worker API)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Callable
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import SessionLocal
|
||||
from app.models import Host
|
||||
from app.services.agent_update import execute_agent_update_fallback
|
||||
from app.services.agent_update_types import AgentUpdateFallbackResult
|
||||
from app.services.linux_admin_settings import get_effective_linux_admin_for_host
|
||||
from app.services.agent_update_settings import get_effective_agent_update_config
|
||||
from app.services.ssh_connect import (
|
||||
iter_ssh_targets,
|
||||
run_ssh_monitor_update,
|
||||
SshCommandResult,
|
||||
tail_ssh_monitor_update_log,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ActionRunner = Callable[[Session, Host], AgentUpdateFallbackResult | SshCommandResult]
|
||||
|
||||
_lock = threading.Lock()
|
||||
_running: set[int] = set()
|
||||
|
||||
|
||||
class RemoteActionAlreadyRunningError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _run_inline() -> bool:
|
||||
return os.environ.get("SAC_REMOTE_ACTION_INLINE", "").strip().lower() in ("1", "true", "yes")
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _format_output(result: AgentUpdateFallbackResult | SshCommandResult) -> str:
|
||||
parts: list[str] = []
|
||||
stdout = (getattr(result, "stdout", None) or "").strip()
|
||||
stderr = (getattr(result, "stderr", None) or "").strip()
|
||||
if stdout:
|
||||
parts.append(stdout)
|
||||
if stderr:
|
||||
parts.append(stderr)
|
||||
exit_code = getattr(result, "exit_code", None)
|
||||
if exit_code is not None:
|
||||
parts.append(f"exit code: {exit_code}")
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
def _result_payload(
|
||||
result: AgentUpdateFallbackResult | SshCommandResult,
|
||||
*,
|
||||
title: str,
|
||||
started_at: str,
|
||||
) -> dict[str, Any]:
|
||||
finished = _utcnow().isoformat()
|
||||
product_version = getattr(result, "product_version", None) or getattr(result, "agent_version", None)
|
||||
return {
|
||||
"title": title,
|
||||
"status": "success" if result.ok else "failed",
|
||||
"message": result.message,
|
||||
"stdout": getattr(result, "stdout", None) or "",
|
||||
"stderr": getattr(result, "stderr", None) or "",
|
||||
"output": _format_output(result),
|
||||
"ok": result.ok,
|
||||
"exit_code": getattr(result, "exit_code", None),
|
||||
"product_version": product_version,
|
||||
"target": getattr(result, "target", None) or "",
|
||||
"started_at": started_at,
|
||||
"finished_at": finished,
|
||||
}
|
||||
|
||||
|
||||
def _completion_title(base_title: str, result: SshCommandResult) -> str:
|
||||
if result.ok:
|
||||
return f"{base_title} — готово"
|
||||
return f"{base_title} — ошибка"
|
||||
|
||||
|
||||
def _completion_message(result: SshCommandResult) -> str:
|
||||
if result.ok:
|
||||
version = (result.agent_version or "").strip()
|
||||
if version:
|
||||
return f"Обновление завершено успешно. Версия агента: {version}"
|
||||
return "Обновление завершено успешно"
|
||||
return (result.message or "Обновление не удалось").strip()
|
||||
|
||||
|
||||
def _fetch_ssh_update_log_tail(db: Session, host: Host, *, lines: int = 200) -> str:
|
||||
cfg = get_effective_linux_admin_for_host(db, host)
|
||||
if not cfg.configured:
|
||||
return ""
|
||||
try:
|
||||
targets = iter_ssh_targets(host)
|
||||
except Exception:
|
||||
return ""
|
||||
for target in targets:
|
||||
try:
|
||||
tail = tail_ssh_monitor_update_log(
|
||||
target=target,
|
||||
user=cfg.user,
|
||||
password=cfg.password,
|
||||
lines=lines,
|
||||
)
|
||||
if tail:
|
||||
return tail
|
||||
except Exception:
|
||||
logger.debug("final update log tail failed host_id=%s target=%s", host.id, target, exc_info=True)
|
||||
return ""
|
||||
|
||||
|
||||
def _enrich_ssh_update_payload(
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
base_title: str,
|
||||
result: SshCommandResult,
|
||||
previous_output: str,
|
||||
log_tail: str,
|
||||
) -> dict[str, Any]:
|
||||
payload["title"] = _completion_title(base_title, result)
|
||||
payload["message"] = _completion_message(result)
|
||||
if log_tail:
|
||||
payload["output"] = log_tail
|
||||
elif previous_output.strip():
|
||||
payload["output"] = previous_output
|
||||
return payload
|
||||
|
||||
|
||||
def _mark_running(db: Session, host: Host, *, title: str) -> str:
|
||||
started_at = _utcnow().isoformat()
|
||||
host.agent_update_state = "running"
|
||||
host.agent_update_last_error = None
|
||||
host.remote_action = {
|
||||
"title": title,
|
||||
"status": "running",
|
||||
"message": "Подключение к хосту и выполнение команды…",
|
||||
"stdout": "",
|
||||
"stderr": "",
|
||||
"output": "",
|
||||
"ok": None,
|
||||
"exit_code": None,
|
||||
"product_version": None,
|
||||
"target": host.hostname,
|
||||
"started_at": started_at,
|
||||
"finished_at": None,
|
||||
}
|
||||
db.flush()
|
||||
return started_at
|
||||
|
||||
|
||||
def _apply_ssh_update_result(db: Session, host: Host, result: SshCommandResult) -> None:
|
||||
now = _utcnow()
|
||||
host.agent_update_last_at = now
|
||||
if result.ok:
|
||||
host.agent_update_state = "success"
|
||||
host.agent_update_last_error = None
|
||||
if result.agent_version:
|
||||
host.product_version = result.agent_version
|
||||
host.ssh_admin_ok = True
|
||||
host.ssh_admin_checked_at = now
|
||||
else:
|
||||
host.agent_update_state = "failed"
|
||||
host.agent_update_last_error = (result.message or "SSH update failed")[:2000]
|
||||
|
||||
|
||||
_REMOTE_LOG_POLL_SEC = 2.0
|
||||
|
||||
|
||||
def _poll_ssh_update_log_loop(
|
||||
host_id: int,
|
||||
*,
|
||||
stop: threading.Event,
|
||||
) -> None:
|
||||
"""Периодически подтягивает tail update_script.log в hosts.remote_action для UI."""
|
||||
while not stop.wait(_REMOTE_LOG_POLL_SEC):
|
||||
session = SessionLocal()
|
||||
try:
|
||||
row = session.get(Host, host_id)
|
||||
if row is None or (row.agent_update_state or "").strip().lower() != "running":
|
||||
continue
|
||||
payload = dict(row.remote_action or {})
|
||||
if (payload.get("status") or "").strip().lower() != "running":
|
||||
continue
|
||||
cfg = get_effective_linux_admin_for_host(session, row)
|
||||
if not cfg.configured:
|
||||
continue
|
||||
try:
|
||||
targets = iter_ssh_targets(row)
|
||||
except Exception:
|
||||
continue
|
||||
tail = ""
|
||||
for target in targets:
|
||||
try:
|
||||
tail = tail_ssh_monitor_update_log(
|
||||
target=target,
|
||||
user=cfg.user,
|
||||
password=cfg.password,
|
||||
lines=200,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("update log tail failed host_id=%s target=%s", host_id, target, exc_info=True)
|
||||
continue
|
||||
if tail:
|
||||
break
|
||||
session.refresh(row)
|
||||
if (row.agent_update_state or "").strip().lower() != "running":
|
||||
continue
|
||||
payload = dict(row.remote_action or {})
|
||||
if (payload.get("status") or "").strip().lower() != "running":
|
||||
continue
|
||||
if tail:
|
||||
payload["output"] = tail
|
||||
payload["message"] = "Выполняется обновление… (лог с хоста)"
|
||||
row.remote_action = payload
|
||||
session.commit()
|
||||
except Exception:
|
||||
logger.debug("remote log poll failed host_id=%s", host_id, exc_info=True)
|
||||
session.rollback()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def start_host_remote_action(
|
||||
db: Session,
|
||||
host: Host,
|
||||
*,
|
||||
title: str,
|
||||
runner: ActionRunner,
|
||||
poll_remote_log: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
host_id = int(host.id)
|
||||
with _lock:
|
||||
if host_id in _running or host.agent_update_state == "running":
|
||||
raise RemoteActionAlreadyRunningError(
|
||||
f"Remote action already running for host {host.hostname}"
|
||||
)
|
||||
_running.add(host_id)
|
||||
|
||||
started_at = _mark_running(db, host, title=title)
|
||||
db.commit()
|
||||
|
||||
def _worker() -> None:
|
||||
session = SessionLocal()
|
||||
stop_poll = threading.Event()
|
||||
poll_thread: threading.Thread | None = None
|
||||
if poll_remote_log and runner is run_ssh_monitor_update_action:
|
||||
poll_thread = threading.Thread(
|
||||
target=_poll_ssh_update_log_loop,
|
||||
args=(host_id,),
|
||||
kwargs={"stop": stop_poll},
|
||||
name=f"sac-remote-log-{host_id}",
|
||||
daemon=True,
|
||||
)
|
||||
poll_thread.start()
|
||||
try:
|
||||
row = session.get(Host, host_id)
|
||||
if row is None:
|
||||
return
|
||||
previous_output = (row.remote_action or {}).get("output") or ""
|
||||
result = runner(session, row)
|
||||
started = (row.remote_action or {}).get("started_at") or started_at
|
||||
payload = _result_payload(result, title=title, started_at=started)
|
||||
if poll_remote_log and isinstance(result, SshCommandResult):
|
||||
log_tail = _fetch_ssh_update_log_tail(session, row)
|
||||
payload = _enrich_ssh_update_payload(
|
||||
payload,
|
||||
base_title=title,
|
||||
result=result,
|
||||
previous_output=previous_output,
|
||||
log_tail=log_tail,
|
||||
)
|
||||
row.remote_action = payload
|
||||
if isinstance(result, SshCommandResult):
|
||||
_apply_ssh_update_result(session, row, result)
|
||||
session.commit()
|
||||
except Exception:
|
||||
logger.exception("Background remote action failed for host_id=%s", host_id)
|
||||
session.rollback()
|
||||
row = session.get(Host, host_id)
|
||||
if row is not None:
|
||||
started = (row.remote_action or {}).get("started_at") or started_at
|
||||
row.agent_update_state = "failed"
|
||||
row.agent_update_last_error = "Internal error during remote action"
|
||||
row.remote_action = {
|
||||
"title": title,
|
||||
"status": "failed",
|
||||
"message": "Внутренняя ошибка SAC при выполнении действия",
|
||||
"stdout": "",
|
||||
"stderr": "",
|
||||
"output": "",
|
||||
"ok": False,
|
||||
"exit_code": None,
|
||||
"product_version": None,
|
||||
"target": row.hostname,
|
||||
"started_at": started,
|
||||
"finished_at": _utcnow().isoformat(),
|
||||
}
|
||||
session.commit()
|
||||
finally:
|
||||
stop_poll.set()
|
||||
if poll_thread is not None:
|
||||
poll_thread.join(timeout=5.0)
|
||||
session.close()
|
||||
with _lock:
|
||||
_running.discard(host_id)
|
||||
|
||||
if _run_inline():
|
||||
_worker()
|
||||
else:
|
||||
threading.Thread(target=_worker, name=f"sac-remote-action-{host_id}", daemon=True).start()
|
||||
return dict(host.remote_action or {})
|
||||
|
||||
|
||||
def run_ssh_monitor_update_action(db: Session, host: Host) -> SshCommandResult:
|
||||
cfg = get_effective_linux_admin_for_host(db, host)
|
||||
if not cfg.configured:
|
||||
return SshCommandResult(
|
||||
ok=False,
|
||||
message="Linux SSH admin is not configured",
|
||||
target=host.hostname or "",
|
||||
)
|
||||
agent_cfg = get_effective_agent_update_config(db)
|
||||
repo_url = (agent_cfg.ssh_git_repo_url or "").strip() or None
|
||||
branch = (agent_cfg.git_branch or "main").strip() or "main"
|
||||
try:
|
||||
targets = iter_ssh_targets(host)
|
||||
except Exception as exc:
|
||||
return SshCommandResult(
|
||||
ok=False,
|
||||
message=str(exc),
|
||||
target=host.hostname or "",
|
||||
)
|
||||
last: SshCommandResult | None = None
|
||||
for target in targets:
|
||||
last = run_ssh_monitor_update(
|
||||
target=target,
|
||||
user=cfg.user,
|
||||
password=cfg.password,
|
||||
repo_url=repo_url,
|
||||
git_branch=branch,
|
||||
)
|
||||
if last.ok:
|
||||
return last
|
||||
return last or SshCommandResult(
|
||||
ok=False,
|
||||
message="SSH update failed",
|
||||
target=host.hostname or "",
|
||||
)
|
||||
|
||||
|
||||
def run_agent_fallback_action(db: Session, host: Host) -> AgentUpdateFallbackResult:
|
||||
return execute_agent_update_fallback(db, host)
|
||||
|
||||
|
||||
def clear_stale_running_remote_actions(
|
||||
db: Session,
|
||||
*,
|
||||
reason: str = "Прервано перезапуском SAC (фоновый worker не завершил job)",
|
||||
host_ids: list[int] | None = None,
|
||||
) -> list[str]:
|
||||
"""Сбросить зависшие running после restart sac-api (daemon thread умер, запись в БД осталась)."""
|
||||
from sqlalchemy import select
|
||||
|
||||
stmt = select(Host).where(Host.agent_update_state == "running")
|
||||
if host_ids is not None:
|
||||
stmt = stmt.where(Host.id.in_(host_ids))
|
||||
rows = list(db.scalars(stmt).all())
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
finished = _utcnow().isoformat()
|
||||
hostnames: list[str] = []
|
||||
for host in rows:
|
||||
started = (host.remote_action or {}).get("started_at")
|
||||
payload = dict(host.remote_action or {})
|
||||
payload.update(
|
||||
{
|
||||
"title": payload.get("title") or "Remote action",
|
||||
"status": "failed",
|
||||
"message": reason,
|
||||
"ok": False,
|
||||
"finished_at": finished,
|
||||
"started_at": started,
|
||||
"target": payload.get("target") or host.hostname,
|
||||
}
|
||||
)
|
||||
host.remote_action = payload
|
||||
host.agent_update_state = "failed"
|
||||
host.agent_update_last_error = reason[:2000]
|
||||
hostnames.append(host.hostname or str(host.id))
|
||||
|
||||
db.commit()
|
||||
with _lock:
|
||||
for host in rows:
|
||||
_running.discard(int(host.id))
|
||||
return hostnames
|
||||
|
||||
|
||||
def cancel_host_remote_action(db: Session, host: Host, *, reason: str | None = None) -> bool:
|
||||
"""Отменить зависший running job вручную (admin)."""
|
||||
if host.agent_update_state != "running":
|
||||
return False
|
||||
msg = reason or "Отменено администратором SAC"
|
||||
cleared = clear_stale_running_remote_actions(db, reason=msg, host_ids=[int(host.id)])
|
||||
return bool(cleared)
|
||||
|
||||
|
||||
def get_remote_action_status(host: Host) -> dict[str, Any]:
|
||||
payload = dict(host.remote_action or {})
|
||||
if not payload:
|
||||
return {"active": False, "host_id": host.id}
|
||||
agent_state = (host.agent_update_state or "").strip().lower()
|
||||
if agent_state == "running":
|
||||
active = True
|
||||
status = payload.get("status") or "running"
|
||||
elif agent_state in ("success", "failed"):
|
||||
active = False
|
||||
status = payload.get("status") or agent_state
|
||||
else:
|
||||
status = payload.get("status") or host.agent_update_state or "unknown"
|
||||
active = status == "running"
|
||||
return {
|
||||
"active": active,
|
||||
"host_id": host.id,
|
||||
"hostname": host.hostname,
|
||||
"status": status,
|
||||
"title": payload.get("title") or "",
|
||||
"message": payload.get("message") or "",
|
||||
"stdout": payload.get("stdout") or "",
|
||||
"stderr": payload.get("stderr") or "",
|
||||
"output": payload.get("output") or "",
|
||||
"ok": payload.get("ok"),
|
||||
"exit_code": payload.get("exit_code"),
|
||||
"product_version": payload.get("product_version") or host.product_version,
|
||||
"target": payload.get("target") or host.hostname,
|
||||
"agent_update_state": host.agent_update_state,
|
||||
"started_at": payload.get("started_at"),
|
||||
"finished_at": payload.get("finished_at"),
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
"""Live user sessions on hosts (Linux loginctl / Windows qwinsta) via SSH or WinRM."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Event, Host
|
||||
from app.services.event_actor_user import extract_event_actor_user
|
||||
from app.services.linux_admin_settings import LinuxAdminConfig
|
||||
from app.services.ssh_connect import (
|
||||
HostNotLinuxError as SshHostNotLinuxError,
|
||||
HostTargetMissingError as SshHostTargetMissingError,
|
||||
SshCommandResult,
|
||||
is_linux_host,
|
||||
iter_ssh_targets,
|
||||
run_ssh_command,
|
||||
)
|
||||
from app.services.win_admin_settings import WinAdminConfig
|
||||
from app.services.winrm_connect import (
|
||||
HostNotWindowsError,
|
||||
HostTargetMissingError,
|
||||
WinRmCmdResult,
|
||||
is_windows_host,
|
||||
run_winrm_logoff,
|
||||
run_winrm_on_host_targets,
|
||||
run_winrm_qwinsta,
|
||||
)
|
||||
|
||||
SESSION_EVENT_TYPES_LINUX = frozenset(
|
||||
{
|
||||
"session.logind.new",
|
||||
"ssh.login.success",
|
||||
"privilege.sudo.command",
|
||||
}
|
||||
)
|
||||
SESSION_EVENT_TYPES_WINDOWS = frozenset({"rdp.login.success"})
|
||||
|
||||
SESSION_TERMINATED_AT_KEY = "session_terminated_at"
|
||||
SESSION_TERMINATED_BY_KEY = "session_terminated_by"
|
||||
|
||||
_LOGIND_SESSION_ID_RE = re.compile(r"^\d+$|^c\d+$", re.IGNORECASE)
|
||||
_LOGIND_USER_RE = re.compile(r"^[a-z_][a-z0-9._-]*$", re.IGNORECASE)
|
||||
_LOGIND_STATES = frozenset(
|
||||
{
|
||||
"active",
|
||||
"online",
|
||||
"closing",
|
||||
"opening",
|
||||
"degraded",
|
||||
"lingering",
|
||||
"preparing",
|
||||
"unknown",
|
||||
}
|
||||
)
|
||||
_LOGIND_TTY_RE = re.compile(r"^(pts|tty)/", re.IGNORECASE)
|
||||
_NO_SESSION_MARKERS = ("no session", "unknown session", "does not exist")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HostSessionRow:
|
||||
session_id: str
|
||||
user: str
|
||||
tty: str | None = None
|
||||
state: str | None = None
|
||||
source_ip: str | None = None
|
||||
session_name: str | None = None
|
||||
|
||||
|
||||
def _details_dict(event: Event) -> dict:
|
||||
raw = event.details
|
||||
return raw if isinstance(raw, dict) else {}
|
||||
|
||||
|
||||
def _event_login_user(event: Event) -> str:
|
||||
actor = extract_event_actor_user(event.type, event.details)
|
||||
if actor:
|
||||
return actor.strip()
|
||||
return str(_details_dict(event).get("user") or "").strip()
|
||||
|
||||
|
||||
def event_session_terminated(event: Event, db: Session | None = None) -> bool:
|
||||
from app.services.rdp_session_logoff import (
|
||||
event_closed_by_logoff,
|
||||
resolve_workstation_login_closed_by_logoff,
|
||||
)
|
||||
from app.services.rdg_workstation_session import (
|
||||
event_closed_by_rdg,
|
||||
resolve_workstation_login_closed,
|
||||
)
|
||||
|
||||
details = _details_dict(event)
|
||||
if details.get("session_terminated") is True:
|
||||
return True
|
||||
at = details.get(SESSION_TERMINATED_AT_KEY)
|
||||
if at is not None and str(at).strip() != "":
|
||||
return True
|
||||
if event_closed_by_rdg(event):
|
||||
return True
|
||||
if event_closed_by_logoff(event):
|
||||
return True
|
||||
if db is not None and event.type == "rdp.login.success":
|
||||
if resolve_workstation_login_closed_by_logoff(db, event):
|
||||
return True
|
||||
return resolve_workstation_login_closed(db, event)
|
||||
return False
|
||||
|
||||
|
||||
def mark_event_session_terminated(event: Event, *, by_username: str | None = None) -> None:
|
||||
details = dict(_details_dict(event))
|
||||
details[SESSION_TERMINATED_AT_KEY] = datetime.now(timezone.utc).isoformat()
|
||||
if by_username and by_username.strip():
|
||||
details[SESSION_TERMINATED_BY_KEY] = by_username.strip()
|
||||
event.details = details
|
||||
|
||||
|
||||
def event_session_id(event: Event) -> str | None:
|
||||
details = _details_dict(event)
|
||||
for key in ("session_id", "sid"):
|
||||
val = details.get(key)
|
||||
if val is not None and str(val).strip():
|
||||
return str(val).strip()
|
||||
return None
|
||||
|
||||
|
||||
def event_supports_session_terminate(event: Event) -> bool:
|
||||
if event.type in SESSION_EVENT_TYPES_LINUX:
|
||||
return is_linux_host_event(event)
|
||||
if event.type in SESSION_EVENT_TYPES_WINDOWS:
|
||||
return is_windows_host_event(event)
|
||||
return False
|
||||
|
||||
|
||||
def is_linux_host_event(event: Event) -> bool:
|
||||
host = event.host
|
||||
return host is not None and is_linux_host(host)
|
||||
|
||||
|
||||
def is_windows_host_event(event: Event) -> bool:
|
||||
host = event.host
|
||||
return host is not None and is_windows_host(host)
|
||||
|
||||
|
||||
def _normalize_logind_tty(value: object) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
if not text or text == "-":
|
||||
return None
|
||||
return text
|
||||
|
||||
|
||||
def _logind_row_priority(row: HostSessionRow) -> tuple[int, int]:
|
||||
"""Prefer interactive TTY sessions over ephemeral scope/SSH-exec rows (tty «-»)."""
|
||||
has_tty = 1 if row.tty and _LOGIND_TTY_RE.match(row.tty) else 0
|
||||
try:
|
||||
sid_num = int(row.session_id)
|
||||
except ValueError:
|
||||
sid_num = 0
|
||||
return (has_tty, sid_num)
|
||||
|
||||
|
||||
def filter_logind_session_rows(rows: list[HostSessionRow]) -> list[HostSessionRow]:
|
||||
"""Drop no-TTY duplicates when the same user already has a pts/tty session."""
|
||||
if len(rows) < 2:
|
||||
return rows
|
||||
|
||||
by_user: dict[str, list[HostSessionRow]] = {}
|
||||
for row in rows:
|
||||
key = row.user.casefold()
|
||||
by_user.setdefault(key, []).append(row)
|
||||
|
||||
out: list[HostSessionRow] = []
|
||||
for group in by_user.values():
|
||||
if len(group) == 1:
|
||||
out.append(group[0])
|
||||
continue
|
||||
with_tty = [r for r in group if r.tty and _LOGIND_TTY_RE.match(r.tty)]
|
||||
if with_tty:
|
||||
out.append(max(with_tty, key=_logind_row_priority))
|
||||
continue
|
||||
out.append(max(group, key=_logind_row_priority))
|
||||
out.sort(key=lambda r: (r.user.casefold(), -_logind_row_priority(r)[1]))
|
||||
return out
|
||||
|
||||
|
||||
def parse_loginctl_sessions_json(stdout: str) -> list[HostSessionRow]:
|
||||
text = stdout.strip()
|
||||
if not text:
|
||||
return []
|
||||
try:
|
||||
payload = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
if not isinstance(payload, list):
|
||||
return []
|
||||
|
||||
rows: list[HostSessionRow] = []
|
||||
for item in payload:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
sid = str(item.get("session") or "").strip()
|
||||
user = str(item.get("user") or "").strip()
|
||||
if not sid or not user:
|
||||
continue
|
||||
if not _LOGIND_SESSION_ID_RE.match(sid):
|
||||
continue
|
||||
if not _LOGIND_USER_RE.match(user):
|
||||
continue
|
||||
state = str(item.get("state") or "").strip().lower() or None
|
||||
if state and state not in _LOGIND_STATES:
|
||||
continue
|
||||
rows.append(
|
||||
HostSessionRow(
|
||||
session_id=sid,
|
||||
user=user,
|
||||
tty=_normalize_logind_tty(item.get("tty")),
|
||||
state=state,
|
||||
)
|
||||
)
|
||||
return filter_logind_session_rows(rows)
|
||||
|
||||
|
||||
def _looks_like_loginctl_row(parts: list[str]) -> bool:
|
||||
if len(parts) < 6:
|
||||
return False
|
||||
sid, uid, user, state = parts[0], parts[1], parts[2], parts[5].lower()
|
||||
if not _LOGIND_SESSION_ID_RE.match(sid):
|
||||
return False
|
||||
if not uid.isdigit():
|
||||
return False
|
||||
if not _LOGIND_USER_RE.match(user):
|
||||
return False
|
||||
return state in _LOGIND_STATES
|
||||
|
||||
|
||||
def parse_loginctl_sessions(stdout: str) -> list[HostSessionRow]:
|
||||
rows: list[HostSessionRow] = []
|
||||
for line in stdout.splitlines():
|
||||
text = line.strip()
|
||||
if not text or text.startswith("SESSION"):
|
||||
continue
|
||||
parts = text.split()
|
||||
if not _looks_like_loginctl_row(parts):
|
||||
continue
|
||||
sid, user = parts[0], parts[2]
|
||||
tty = parts[4] if len(parts) > 4 and parts[4] != "-" else None
|
||||
state = parts[5] if len(parts) > 5 else None
|
||||
rows.append(
|
||||
HostSessionRow(
|
||||
session_id=sid,
|
||||
user=user,
|
||||
tty=_normalize_logind_tty(tty),
|
||||
state=state,
|
||||
)
|
||||
)
|
||||
return filter_logind_session_rows(rows)
|
||||
|
||||
|
||||
def _shell_quote(value: str) -> str:
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
|
||||
def _linux_session_exists_message(stderr: str, stdout: str) -> bool:
|
||||
blob = f"{stderr}\n{stdout}".casefold()
|
||||
return any(marker in blob for marker in _NO_SESSION_MARKERS)
|
||||
|
||||
|
||||
def _linux_show_session_user(host: Host, cfg: LinuxAdminConfig, session_id: str) -> str | None:
|
||||
sid = session_id.strip()
|
||||
if not sid:
|
||||
return None
|
||||
remote_cmd = f"loginctl show-session {_shell_quote(sid)} -p Name --value"
|
||||
targets = iter_ssh_targets(host)
|
||||
for target in targets:
|
||||
result = run_ssh_command(
|
||||
target=target,
|
||||
user=cfg.user,
|
||||
password=cfg.password,
|
||||
remote_cmd=remote_cmd,
|
||||
connect_timeout_sec=15,
|
||||
command_timeout_sec=30,
|
||||
need_root=True,
|
||||
login_shell=False,
|
||||
)
|
||||
if not result.ok:
|
||||
continue
|
||||
user = result.stdout.strip().splitlines()[-1].strip() if result.stdout.strip() else ""
|
||||
if user and _LOGIND_USER_RE.match(user):
|
||||
return user
|
||||
return None
|
||||
|
||||
|
||||
def list_linux_sessions(host: Host, cfg: LinuxAdminConfig) -> tuple[list[HostSessionRow], SshCommandResult]:
|
||||
if not is_linux_host(host):
|
||||
raise SshHostNotLinuxError("Host is not Linux")
|
||||
targets = iter_ssh_targets(host)
|
||||
json_cmd = "loginctl list-sessions --output=json --no-legend"
|
||||
text_cmd = "loginctl list-sessions --no-legend --no-pager"
|
||||
last: SshCommandResult | None = None
|
||||
for target in targets:
|
||||
result = run_ssh_command(
|
||||
target=target,
|
||||
user=cfg.user,
|
||||
password=cfg.password,
|
||||
remote_cmd=json_cmd,
|
||||
connect_timeout_sec=15,
|
||||
command_timeout_sec=45,
|
||||
need_root=True,
|
||||
login_shell=False,
|
||||
)
|
||||
last = result
|
||||
if result.ok and result.stdout.strip():
|
||||
rows = parse_loginctl_sessions_json(result.stdout)
|
||||
if rows:
|
||||
return rows, result
|
||||
rows = parse_loginctl_sessions(result.stdout)
|
||||
if rows:
|
||||
return rows, result
|
||||
result = run_ssh_command(
|
||||
target=target,
|
||||
user=cfg.user,
|
||||
password=cfg.password,
|
||||
remote_cmd=text_cmd,
|
||||
connect_timeout_sec=15,
|
||||
command_timeout_sec=45,
|
||||
need_root=True,
|
||||
login_shell=False,
|
||||
)
|
||||
last = result
|
||||
if result.ok and result.stdout.strip():
|
||||
return parse_loginctl_sessions(result.stdout), result
|
||||
assert last is not None
|
||||
return [], last
|
||||
|
||||
|
||||
def terminate_linux_session(
|
||||
host: Host,
|
||||
cfg: LinuxAdminConfig,
|
||||
session_id: str,
|
||||
) -> SshCommandResult:
|
||||
sid = session_id.strip()
|
||||
if not sid:
|
||||
return SshCommandResult(ok=False, message="session_id is required", target="")
|
||||
if not is_linux_host(host):
|
||||
raise SshHostNotLinuxError("Host is not Linux")
|
||||
targets = iter_ssh_targets(host)
|
||||
remote_cmd = f"loginctl terminate-session {_shell_quote(sid)}"
|
||||
last: SshCommandResult | None = None
|
||||
session_user = _linux_show_session_user(host, cfg, sid)
|
||||
for target in targets:
|
||||
result = run_ssh_command(
|
||||
target=target,
|
||||
user=cfg.user,
|
||||
password=cfg.password,
|
||||
remote_cmd=remote_cmd,
|
||||
connect_timeout_sec=15,
|
||||
command_timeout_sec=45,
|
||||
need_root=True,
|
||||
login_shell=False,
|
||||
)
|
||||
last = result
|
||||
if result.ok:
|
||||
return result
|
||||
if session_user:
|
||||
return _terminate_linux_user_sessions(host, cfg, session_user)
|
||||
if last is not None and _linux_session_exists_message(last.stderr, last.stdout):
|
||||
return SshCommandResult(
|
||||
ok=False,
|
||||
message=(
|
||||
f"Сессия {sid} не найдена (устарела). Обновите список и повторите "
|
||||
"или завершите все сессии пользователя."
|
||||
),
|
||||
target=last.target,
|
||||
stdout=last.stdout,
|
||||
stderr=last.stderr,
|
||||
exit_code=last.exit_code,
|
||||
)
|
||||
assert last is not None
|
||||
return last
|
||||
|
||||
|
||||
def _normalize_sam_account(user: str) -> str:
|
||||
text = (user or "").strip()
|
||||
if "\\" in text:
|
||||
return text.split("\\")[-1].strip().casefold()
|
||||
if "@" in text:
|
||||
return text.split("@")[0].strip().casefold()
|
||||
return text.casefold()
|
||||
|
||||
|
||||
def windows_user_matches_session(login_user: str, session_user: str) -> bool:
|
||||
login = _normalize_sam_account(login_user)
|
||||
session = _normalize_sam_account(session_user)
|
||||
return bool(login and session and login == session)
|
||||
|
||||
|
||||
def filter_windows_sessions_for_user(
|
||||
sessions: list[HostSessionRow],
|
||||
login_user: str,
|
||||
) -> list[HostSessionRow]:
|
||||
user = (login_user or "").strip()
|
||||
if not user:
|
||||
return sessions
|
||||
return [s for s in sessions if windows_user_matches_session(user, s.user)]
|
||||
|
||||
|
||||
def _qwinsta_sam_account(value: str) -> str:
|
||||
text = (value or "").strip()
|
||||
if "\\" in text:
|
||||
text = text.split("\\")[-1]
|
||||
if "@" in text:
|
||||
text = text.split("@")[0]
|
||||
return text.casefold()
|
||||
|
||||
|
||||
def parse_qwinsta_sessions(stdout: str, *, filter_user: str | None = None) -> list[HostSessionRow]:
|
||||
"""Parse ``qwinsta`` output.
|
||||
|
||||
Disconnected sessions often have an empty SESSIONNAME column, so the line
|
||||
becomes ``USERNAME ID STATE`` (3 tokens). Older parsing required 4 tokens
|
||||
and skipped those rows — that broke RDG flap auto-logoff for Disc sessions.
|
||||
"""
|
||||
rows: list[HostSessionRow] = []
|
||||
filter_sam = _qwinsta_sam_account(filter_user or "")
|
||||
skip_users = frozenset({"services"})
|
||||
|
||||
for line in stdout.splitlines():
|
||||
text = line.strip()
|
||||
if not text or re.match(r"^SESSION", text, re.I) or text.startswith("---"):
|
||||
continue
|
||||
parts = text.split()
|
||||
id_idx: int | None = None
|
||||
sid = 0
|
||||
for i, part in enumerate(parts):
|
||||
token = part.lstrip(">")
|
||||
if token.isdigit():
|
||||
id_idx = i
|
||||
sid = int(token)
|
||||
break
|
||||
if id_idx is None or id_idx < 1:
|
||||
continue
|
||||
state = " ".join(parts[id_idx + 1 :]) if id_idx + 1 < len(parts) else ""
|
||||
if not state or state.casefold().startswith("listen"):
|
||||
continue
|
||||
before = parts[:id_idx]
|
||||
if len(before) == 1:
|
||||
session_name = ""
|
||||
user_name = before[0].lstrip(">")
|
||||
else:
|
||||
session_name = before[0].lstrip(">")
|
||||
user_name = before[1]
|
||||
if user_name.casefold() in skip_users:
|
||||
continue
|
||||
if filter_sam and filter_sam not in _qwinsta_sam_account(user_name):
|
||||
continue
|
||||
rows.append(
|
||||
HostSessionRow(
|
||||
session_id=str(sid),
|
||||
user=user_name,
|
||||
session_name=session_name,
|
||||
state=state,
|
||||
)
|
||||
)
|
||||
|
||||
if rows or not filter_sam:
|
||||
return rows
|
||||
|
||||
return parse_qwinsta_sessions(stdout, filter_user=None)
|
||||
|
||||
|
||||
def list_windows_sessions(host: Host, cfg: WinAdminConfig) -> tuple[list[HostSessionRow], WinRmCmdResult | None]:
|
||||
if not is_windows_host(host):
|
||||
raise HostNotWindowsError("Host is not Windows")
|
||||
|
||||
def action(target: str) -> WinRmCmdResult:
|
||||
return run_winrm_qwinsta(target=target, user=cfg.user, password=cfg.password)
|
||||
|
||||
result, _attempts = run_winrm_on_host_targets(host, user=cfg.user, password=cfg.password, action=action)
|
||||
if result is None or not result.ok:
|
||||
return [], result
|
||||
return parse_qwinsta_sessions(result.stdout), result
|
||||
|
||||
|
||||
def terminate_windows_session(
|
||||
host: Host,
|
||||
cfg: WinAdminConfig,
|
||||
session_id: str,
|
||||
) -> WinRmCmdResult | None:
|
||||
try:
|
||||
sid = int(session_id.strip())
|
||||
except ValueError:
|
||||
return WinRmCmdResult(ok=False, message="Invalid Windows session_id", target="")
|
||||
|
||||
def action(target: str) -> WinRmCmdResult:
|
||||
return run_winrm_logoff(
|
||||
target=target,
|
||||
user=cfg.user,
|
||||
password=cfg.password,
|
||||
session_id=sid,
|
||||
)
|
||||
|
||||
result, _attempts = run_winrm_on_host_targets(host, user=cfg.user, password=cfg.password, action=action)
|
||||
return result
|
||||
|
||||
|
||||
def terminate_session_for_event(
|
||||
event: Event,
|
||||
*,
|
||||
linux_cfg: LinuxAdminConfig,
|
||||
win_cfg: WinAdminConfig,
|
||||
session_id: str | None = None,
|
||||
) -> SshCommandResult | WinRmCmdResult:
|
||||
host = event.host
|
||||
if host is None:
|
||||
raise ValueError("Event has no host")
|
||||
|
||||
sid = (session_id or event_session_id(event) or "").strip()
|
||||
if is_linux_host(host):
|
||||
if not sid:
|
||||
user = _event_login_user(event)
|
||||
if user:
|
||||
return _terminate_linux_user_sessions(host, linux_cfg, user)
|
||||
raise ValueError("session_id is required for this event")
|
||||
return terminate_linux_session(host, linux_cfg, sid)
|
||||
|
||||
if is_windows_host(host):
|
||||
if not sid:
|
||||
user = _event_login_user(event)
|
||||
sessions, qwinsta = list_windows_sessions(host, win_cfg)
|
||||
if not qwinsta or not qwinsta.ok:
|
||||
raise ValueError(qwinsta.message if qwinsta else "qwinsta failed")
|
||||
matched = filter_windows_sessions_for_user(sessions, user) if user else sessions
|
||||
if len(matched) == 1:
|
||||
sid = matched[0].session_id
|
||||
elif not matched:
|
||||
# Пользователь уже вышел из RDP — qwinsta пуст, событие входа в SAC ещё «открыто».
|
||||
return WinRmCmdResult(
|
||||
ok=True,
|
||||
message="На хосте нет активной сессии пользователя (уже вышел из RDP)",
|
||||
target=qwinsta.target,
|
||||
stdout=qwinsta.stdout,
|
||||
)
|
||||
else:
|
||||
raise ValueError("Multiple sessions; specify session_id")
|
||||
result = terminate_windows_session(host, win_cfg, sid)
|
||||
if result is None:
|
||||
raise ValueError("WinRM logoff failed")
|
||||
return result
|
||||
|
||||
raise ValueError("Unsupported host OS for session terminate")
|
||||
|
||||
|
||||
def _terminate_linux_user_sessions(host: Host, cfg: LinuxAdminConfig, user: str) -> SshCommandResult:
|
||||
safe_user = _shell_quote(user.strip())
|
||||
remote_cmd = f"loginctl terminate-user {safe_user}"
|
||||
targets = iter_ssh_targets(host)
|
||||
last: SshCommandResult | None = None
|
||||
for target in targets:
|
||||
result = run_ssh_command(
|
||||
target=target,
|
||||
user=cfg.user,
|
||||
password=cfg.password,
|
||||
remote_cmd=remote_cmd,
|
||||
connect_timeout_sec=15,
|
||||
command_timeout_sec=45,
|
||||
need_root=True,
|
||||
login_shell=False,
|
||||
)
|
||||
last = result
|
||||
if result.ok:
|
||||
return result
|
||||
assert last is not None
|
||||
return last
|
||||
@@ -6,16 +6,19 @@ import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select, text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import Host, Problem
|
||||
from app.services.problem_rules import RuleMatch, build_fingerprint, host_silence_match_for_host
|
||||
from app.services.problems import _correlation_cutoff
|
||||
from app.services.problem_rules import RULE_HOST_SILENCE, RuleMatch, build_fingerprint, host_silence_match_for_host
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ACTIVE_HOST_SILENCE_STATUSES = ("open", "acknowledged")
|
||||
_HOST_SILENCE_SCAN_LOCK_KEY = 915_001_001
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HostSilenceScanResult:
|
||||
@@ -25,45 +28,110 @@ class HostSilenceScanResult:
|
||||
created: bool
|
||||
|
||||
|
||||
def _host_silence_scan_lock(db: Session) -> bool:
|
||||
"""Один scan за раз (uvicorn workers + systemd timer)."""
|
||||
bind = db.get_bind()
|
||||
if bind.dialect.name != "postgresql":
|
||||
return True
|
||||
acquired = db.execute(
|
||||
text("SELECT pg_try_advisory_xact_lock(:key)"),
|
||||
{"key": _HOST_SILENCE_SCAN_LOCK_KEY},
|
||||
).scalar()
|
||||
return bool(acquired)
|
||||
|
||||
|
||||
def _find_active_host_silence_problem(
|
||||
db: Session,
|
||||
*,
|
||||
host_id: int,
|
||||
hostname: str | None,
|
||||
) -> Problem | None:
|
||||
active = db.scalar(
|
||||
select(Problem)
|
||||
.where(
|
||||
Problem.host_id == host_id,
|
||||
Problem.rule_id == RULE_HOST_SILENCE,
|
||||
Problem.status.in_(ACTIVE_HOST_SILENCE_STATUSES),
|
||||
)
|
||||
.order_by(Problem.last_seen_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
if active is not None:
|
||||
return active
|
||||
|
||||
name = (hostname or "").strip()
|
||||
if not name:
|
||||
return None
|
||||
|
||||
sibling = db.scalar(
|
||||
select(Problem)
|
||||
.join(Host, Problem.host_id == Host.id)
|
||||
.where(
|
||||
Problem.rule_id == RULE_HOST_SILENCE,
|
||||
Problem.status.in_(ACTIVE_HOST_SILENCE_STATUSES),
|
||||
func.lower(Host.hostname) == name.lower(),
|
||||
)
|
||||
.order_by(Problem.last_seen_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
if sibling is not None and sibling.host_id != host_id:
|
||||
sibling.host_id = host_id
|
||||
db.flush()
|
||||
return sibling
|
||||
|
||||
|
||||
def _apply_host_silence_refresh(
|
||||
problem: Problem,
|
||||
*,
|
||||
host_id: int,
|
||||
match: RuleMatch,
|
||||
fingerprint: str,
|
||||
ref: datetime,
|
||||
) -> None:
|
||||
problem.host_id = host_id
|
||||
problem.summary = match.summary
|
||||
problem.title = match.title
|
||||
problem.fingerprint = fingerprint
|
||||
problem.last_seen_at = ref
|
||||
problem.updated_at = ref
|
||||
|
||||
|
||||
def open_or_refresh_host_silence_problem(
|
||||
db: Session,
|
||||
host_id: int,
|
||||
match: RuleMatch,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
hostname: str | None = None,
|
||||
) -> tuple[Problem | None, bool]:
|
||||
"""
|
||||
Открыть или обновить open Problem host_silence без ingest-события.
|
||||
Открыть или обновить open/ack Problem host_silence без ingest-события.
|
||||
|
||||
Returns (problem, created). problem is None when suppressed by manual-resolve cooldown.
|
||||
"""
|
||||
ref = now or datetime.now(timezone.utc)
|
||||
if hostname is None:
|
||||
host = db.get(Host, host_id)
|
||||
hostname = host.hostname if host is not None else None
|
||||
|
||||
fingerprint = build_fingerprint(
|
||||
host_id,
|
||||
match.correlation_type,
|
||||
match.rule_id,
|
||||
match.fingerprint_suffix,
|
||||
)
|
||||
cutoff = _correlation_cutoff(ref)
|
||||
|
||||
open_problem = db.scalar(
|
||||
select(Problem)
|
||||
.where(
|
||||
Problem.status == "open",
|
||||
Problem.fingerprint == fingerprint,
|
||||
Problem.host_id == host_id,
|
||||
Problem.last_seen_at >= cutoff,
|
||||
active_problem = _find_active_host_silence_problem(db, host_id=host_id, hostname=hostname)
|
||||
if active_problem is not None:
|
||||
_apply_host_silence_refresh(
|
||||
active_problem,
|
||||
host_id=host_id,
|
||||
match=match,
|
||||
fingerprint=fingerprint,
|
||||
ref=ref,
|
||||
)
|
||||
.order_by(Problem.last_seen_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
if open_problem:
|
||||
open_problem.summary = match.summary
|
||||
open_problem.title = match.title
|
||||
open_problem.last_seen_at = ref
|
||||
open_problem.updated_at = ref
|
||||
db.flush()
|
||||
return open_problem, False
|
||||
return active_problem, False
|
||||
|
||||
settings = get_settings()
|
||||
cooldown_hours = settings.sac_host_silence_manual_resolve_cooldown_hours
|
||||
@@ -105,12 +173,27 @@ def open_or_refresh_host_silence_problem(
|
||||
.limit(1)
|
||||
)
|
||||
if manual_expired is not None:
|
||||
active_problem = _find_active_host_silence_problem(db, host_id=host_id, hostname=hostname)
|
||||
if active_problem is not None:
|
||||
_apply_host_silence_refresh(
|
||||
active_problem,
|
||||
host_id=host_id,
|
||||
match=match,
|
||||
fingerprint=fingerprint,
|
||||
ref=ref,
|
||||
)
|
||||
db.flush()
|
||||
return active_problem, False
|
||||
|
||||
manual_expired.status = "open"
|
||||
manual_expired.resolved_by = None
|
||||
manual_expired.summary = match.summary
|
||||
manual_expired.title = match.title
|
||||
manual_expired.last_seen_at = ref
|
||||
manual_expired.updated_at = ref
|
||||
_apply_host_silence_refresh(
|
||||
manual_expired,
|
||||
host_id=host_id,
|
||||
match=match,
|
||||
fingerprint=fingerprint,
|
||||
ref=ref,
|
||||
)
|
||||
db.flush()
|
||||
return manual_expired, True
|
||||
|
||||
@@ -125,13 +208,32 @@ def open_or_refresh_host_silence_problem(
|
||||
event_count=0,
|
||||
last_seen_at=ref,
|
||||
)
|
||||
db.add(problem)
|
||||
db.flush()
|
||||
try:
|
||||
with db.begin_nested():
|
||||
db.add(problem)
|
||||
db.flush()
|
||||
except IntegrityError:
|
||||
active_problem = _find_active_host_silence_problem(db, host_id=host_id, hostname=hostname)
|
||||
if active_problem is None:
|
||||
raise
|
||||
_apply_host_silence_refresh(
|
||||
active_problem,
|
||||
host_id=host_id,
|
||||
match=match,
|
||||
fingerprint=fingerprint,
|
||||
ref=ref,
|
||||
)
|
||||
db.flush()
|
||||
return active_problem, False
|
||||
return problem, True
|
||||
|
||||
|
||||
def run_host_silence_scan(db: Session, *, now: datetime | None = None) -> list[HostSilenceScanResult]:
|
||||
"""Найти stale-хосты и создать/обновить Problems rule:host_silence."""
|
||||
if not _host_silence_scan_lock(db):
|
||||
logger.debug("host_silence scan skipped (another runner holds advisory lock)")
|
||||
return []
|
||||
|
||||
ref = now or datetime.now(timezone.utc)
|
||||
hosts = db.scalars(select(Host).order_by(Host.id)).all()
|
||||
results: list[HostSilenceScanResult] = []
|
||||
@@ -140,7 +242,13 @@ def run_host_silence_scan(db: Session, *, now: datetime | None = None) -> list[H
|
||||
match = host_silence_match_for_host(db, host.id, hostname=host.hostname, now=ref)
|
||||
if match is None:
|
||||
continue
|
||||
problem, created = open_or_refresh_host_silence_problem(db, host.id, match, now=ref)
|
||||
problem, created = open_or_refresh_host_silence_problem(
|
||||
db,
|
||||
host.id,
|
||||
match,
|
||||
now=ref,
|
||||
hostname=host.hostname,
|
||||
)
|
||||
if problem is None:
|
||||
continue
|
||||
results.append(
|
||||
|
||||
@@ -5,9 +5,12 @@ from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Event, Host
|
||||
from app.services.agent_update import process_agent_update_ingest
|
||||
from app.services.daily_report_format import normalize_daily_report_details
|
||||
from app.services.event_severity_overrides import apply_severity_override
|
||||
from app.services.host_inventory import INVENTORY_EVENT_TYPE, process_inventory_ingest
|
||||
from app.services.rdp_session_logoff import close_workstation_session_for_rdp_logoff
|
||||
from app.services.rdg_workstation_session import close_workstation_session_for_rdg_end
|
||||
|
||||
DAILY_REPORT_TYPES = frozenset({"report.daily.ssh", "report.daily.rdp"})
|
||||
|
||||
@@ -123,4 +126,17 @@ def ingest_event(db: Session, payload: dict) -> tuple[Event, bool]:
|
||||
if raced is not None:
|
||||
return raced, False
|
||||
raise
|
||||
|
||||
process_agent_update_ingest(db, host, payload.get("type", ""), details)
|
||||
from app.services.rdg_workstation_session import (
|
||||
enrich_empty_login_from_rdg_success,
|
||||
enrich_workstation_login_user_from_rdg,
|
||||
)
|
||||
|
||||
if event.host is None:
|
||||
event.host = host
|
||||
enrich_workstation_login_user_from_rdg(db, event)
|
||||
enrich_empty_login_from_rdg_success(db, event)
|
||||
close_workstation_session_for_rdg_end(db, event)
|
||||
close_workstation_session_for_rdp_logoff(db, event)
|
||||
return event, True
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Effective Linux SSH admin credentials (host → DB → env)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models.host import Host
|
||||
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LinuxAdminConfig:
|
||||
user: str
|
||||
password: str
|
||||
source: str # host | env | db
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return bool(self.user.strip() and self.password.strip())
|
||||
|
||||
|
||||
def _linux_admin_from_env() -> LinuxAdminConfig:
|
||||
settings = get_settings()
|
||||
return LinuxAdminConfig(
|
||||
user=settings.sac_linux_admin_user.strip(),
|
||||
password=settings.sac_linux_admin_password,
|
||||
source="env",
|
||||
)
|
||||
|
||||
|
||||
def get_effective_linux_admin_config(db: Session | None = None) -> LinuxAdminConfig:
|
||||
if db is None:
|
||||
from app.database import SessionLocal
|
||||
|
||||
session = SessionLocal()
|
||||
try:
|
||||
return get_effective_linux_admin_config(session)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
|
||||
env_cfg = _linux_admin_from_env()
|
||||
if row is None:
|
||||
return env_cfg
|
||||
|
||||
user = (row.linux_admin_user or "").strip() or env_cfg.user
|
||||
password = (row.linux_admin_password or "") or env_cfg.password
|
||||
has_db_values = bool((row.linux_admin_user or "").strip() or (row.linux_admin_password or "").strip())
|
||||
return LinuxAdminConfig(
|
||||
user=user,
|
||||
password=password,
|
||||
source="db" if has_db_values else env_cfg.source,
|
||||
)
|
||||
|
||||
|
||||
def upsert_linux_admin_settings(
|
||||
db: Session,
|
||||
*,
|
||||
user: str | None = None,
|
||||
password: str | None = None,
|
||||
) -> LinuxAdminConfig:
|
||||
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
|
||||
env_cfg = _linux_admin_from_env()
|
||||
if row is None:
|
||||
row = UiSettings(
|
||||
id=UI_SETTINGS_ROW_ID,
|
||||
show_sidebar_system_stats=True,
|
||||
linux_admin_user=env_cfg.user or None,
|
||||
linux_admin_password=env_cfg.password or None,
|
||||
)
|
||||
db.add(row)
|
||||
|
||||
if user is not None and user.strip():
|
||||
row.linux_admin_user = user.strip()
|
||||
if password is not None and password.strip():
|
||||
row.linux_admin_password = password
|
||||
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return get_effective_linux_admin_config(db)
|
||||
|
||||
|
||||
def get_effective_linux_admin_for_host(db: Session, host: Host) -> LinuxAdminConfig:
|
||||
"""Prefer per-host mgmt_* when both user and password are set; else global Settings/env."""
|
||||
host_user = (host.mgmt_user or "").strip()
|
||||
host_password = (host.mgmt_password or "").strip()
|
||||
if host_user and host_password:
|
||||
return LinuxAdminConfig(user=host_user, password=host_password, source="host")
|
||||
|
||||
global_cfg = get_effective_linux_admin_config(db)
|
||||
if host_user and global_cfg.password.strip():
|
||||
return LinuxAdminConfig(user=host_user, password=global_cfg.password, source="host")
|
||||
return global_cfg
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Rate limit failed SAC UI logins + optional Telegram alert."""
|
||||
"""Rate limit failed SAC UI logins + optional Telegram alert."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -12,6 +12,11 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models.login_attempt import LoginAttempt
|
||||
from app.services.client_ip import client_ip_from_request
|
||||
from app.services.login_security_settings import (
|
||||
get_effective_login_security_config,
|
||||
is_ip_login_whitelisted,
|
||||
)
|
||||
from app.services.telegram_notify import send_telegram_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -33,15 +38,6 @@ def get_login_rate_limit_config() -> LoginRateLimitConfig:
|
||||
)
|
||||
|
||||
|
||||
def client_ip_from_request(request: Request) -> str:
|
||||
forwarded = (request.headers.get("x-forwarded-for") or "").strip()
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()[:64]
|
||||
if request.client and request.client.host:
|
||||
return request.client.host[:64]
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _failure_count(db: Session, ip_address: str, *, since: datetime) -> int:
|
||||
return int(
|
||||
db.scalar(
|
||||
@@ -91,6 +87,9 @@ def ensure_login_allowed(db: Session, request: Request) -> str:
|
||||
"""Raise 429 if IP exceeded failed login threshold. Returns client IP."""
|
||||
cfg = get_login_rate_limit_config()
|
||||
ip_address = client_ip_from_request(request)
|
||||
sec = get_effective_login_security_config(db)
|
||||
if is_ip_login_whitelisted(ip_address, sec.ip_whitelist):
|
||||
return ip_address
|
||||
since = datetime.now(timezone.utc) - timedelta(minutes=cfg.window_minutes)
|
||||
failures = _failure_count(db, ip_address, since=since)
|
||||
if failures >= cfg.max_failures:
|
||||
@@ -119,6 +118,9 @@ def record_login_failure(
|
||||
request: Request | None = None,
|
||||
) -> None:
|
||||
del request # reserved
|
||||
sec = get_effective_login_security_config(db)
|
||||
if is_ip_login_whitelisted(ip_address, sec.ip_whitelist):
|
||||
return
|
||||
cfg = get_login_rate_limit_config()
|
||||
since = datetime.now(timezone.utc) - timedelta(minutes=cfg.window_minutes)
|
||||
prior_failures = _failure_count(db, ip_address, since=since)
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
"""SAC UI login security: IP whitelist, blocked list, unblock."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models.login_attempt import LoginAttempt
|
||||
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
|
||||
from app.services.fail2ban_sync import (
|
||||
Fail2banActionResult,
|
||||
list_ssh_banned_ips,
|
||||
sync_fail2ban_ignore_ips,
|
||||
unban_ssh_ip,
|
||||
)
|
||||
|
||||
_IP_RE = re.compile(
|
||||
r"^(?:(?:25[0-5]|2[0-4]\d|[01]?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d?\d)$"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LoginSecurityConfig:
|
||||
ip_whitelist: tuple[str, ...]
|
||||
sync_fail2ban: bool
|
||||
max_failures: int
|
||||
window_minutes: int
|
||||
source: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LoginBlockEntry:
|
||||
ip_address: str
|
||||
scope: str
|
||||
failure_count: int | None
|
||||
usernames: tuple[str, ...]
|
||||
blocked_until: datetime | None
|
||||
reason: str
|
||||
|
||||
|
||||
def normalize_ip_token(raw: str) -> str | None:
|
||||
text = (raw or "").strip()
|
||||
if not text or text.startswith("#"):
|
||||
return None
|
||||
if _IP_RE.match(text):
|
||||
return text
|
||||
return None
|
||||
|
||||
|
||||
def parse_ip_whitelist_text(text: str) -> list[str]:
|
||||
if not text:
|
||||
return []
|
||||
tokens = re.split(r"[\s,;]+", text.replace("\n", " "))
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for token in tokens:
|
||||
ip = normalize_ip_token(token)
|
||||
if ip and ip not in seen:
|
||||
seen.add(ip)
|
||||
out.append(ip)
|
||||
return out
|
||||
|
||||
|
||||
def _env_whitelist() -> list[str]:
|
||||
settings = get_settings()
|
||||
raw = (getattr(settings, "sac_login_ip_whitelist", None) or "").strip()
|
||||
return parse_ip_whitelist_text(raw.replace(",", " "))
|
||||
|
||||
|
||||
def _db_whitelist(row: UiSettings | None) -> list[str]:
|
||||
if row is None:
|
||||
return []
|
||||
return parse_ip_whitelist_text(row.login_ip_whitelist or "")
|
||||
|
||||
|
||||
def merge_whitelist(*parts: list[str]) -> list[str]:
|
||||
seen: set[str] = set()
|
||||
out: list[str] = []
|
||||
for part in parts:
|
||||
for ip in part:
|
||||
if ip not in seen:
|
||||
seen.add(ip)
|
||||
out.append(ip)
|
||||
return out
|
||||
|
||||
|
||||
def get_effective_login_security_config(db: Session) -> LoginSecurityConfig:
|
||||
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
|
||||
env_ips = _env_whitelist()
|
||||
db_ips = _db_whitelist(row)
|
||||
merged = merge_whitelist(env_ips, db_ips)
|
||||
settings = get_settings()
|
||||
max_failures = max(1, int(getattr(settings, "sac_login_max_failures", 3) or 3))
|
||||
window_minutes = max(1, int(getattr(settings, "sac_login_failure_window_minutes", 15) or 15))
|
||||
sources: list[str] = []
|
||||
if env_ips:
|
||||
sources.append("env")
|
||||
if db_ips:
|
||||
sources.append("db")
|
||||
source = "+".join(sources) if sources else "default"
|
||||
sync_fb = bool(row.login_sync_fail2ban) if row is not None else False
|
||||
return LoginSecurityConfig(
|
||||
ip_whitelist=tuple(merged),
|
||||
sync_fail2ban=sync_fb,
|
||||
max_failures=max_failures,
|
||||
window_minutes=window_minutes,
|
||||
source=source,
|
||||
)
|
||||
|
||||
|
||||
def is_ip_login_whitelisted(ip_address: str, whitelist: tuple[str, ...] | list[str]) -> bool:
|
||||
ip = (ip_address or "").strip()
|
||||
if not ip:
|
||||
return False
|
||||
return ip in whitelist
|
||||
|
||||
|
||||
def upsert_login_security_settings(
|
||||
db: Session,
|
||||
*,
|
||||
ip_whitelist_text: str,
|
||||
sync_fail2ban: bool,
|
||||
) -> LoginSecurityConfig:
|
||||
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
|
||||
if row is None:
|
||||
row = UiSettings(id=UI_SETTINGS_ROW_ID, show_sidebar_system_stats=True)
|
||||
db.add(row)
|
||||
row.login_ip_whitelist = ip_whitelist_text.strip() or None
|
||||
row.login_sync_fail2ban = bool(sync_fail2ban)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
cfg = get_effective_login_security_config(db)
|
||||
if cfg.sync_fail2ban:
|
||||
sync_fail2ban_ignore_ips(list(cfg.ip_whitelist))
|
||||
return cfg
|
||||
|
||||
|
||||
def _window_since(cfg: LoginSecurityConfig) -> datetime:
|
||||
return datetime.now(timezone.utc) - timedelta(minutes=cfg.window_minutes)
|
||||
|
||||
|
||||
def list_web_login_blocks(db: Session) -> list[LoginBlockEntry]:
|
||||
cfg = get_effective_login_security_config(db)
|
||||
since = _window_since(cfg)
|
||||
rows = db.execute(
|
||||
select(
|
||||
LoginAttempt.ip_address,
|
||||
func.count().label("cnt"),
|
||||
func.max(LoginAttempt.created_at).label("last_at"),
|
||||
)
|
||||
.where(
|
||||
LoginAttempt.success.is_(False),
|
||||
LoginAttempt.username != "__alert_sent__",
|
||||
LoginAttempt.created_at >= since,
|
||||
)
|
||||
.group_by(LoginAttempt.ip_address)
|
||||
.having(func.count() >= cfg.max_failures)
|
||||
).all()
|
||||
|
||||
blocks: list[LoginBlockEntry] = []
|
||||
for ip_address, cnt, last_at in rows:
|
||||
ip = str(ip_address)
|
||||
if is_ip_login_whitelisted(ip, cfg.ip_whitelist):
|
||||
continue
|
||||
user_rows = db.scalars(
|
||||
select(LoginAttempt.username)
|
||||
.where(
|
||||
LoginAttempt.ip_address == ip,
|
||||
LoginAttempt.success.is_(False),
|
||||
LoginAttempt.username.isnot(None),
|
||||
LoginAttempt.username != "__alert_sent__",
|
||||
LoginAttempt.created_at >= since,
|
||||
)
|
||||
.distinct()
|
||||
.limit(10)
|
||||
).all()
|
||||
usernames = tuple(sorted({u for u in user_rows if u}))
|
||||
blocked_until = (last_at + timedelta(minutes=cfg.window_minutes)) if last_at else None
|
||||
blocks.append(
|
||||
LoginBlockEntry(
|
||||
ip_address=ip,
|
||||
scope="web",
|
||||
failure_count=int(cnt or 0),
|
||||
usernames=usernames,
|
||||
blocked_until=blocked_until,
|
||||
reason=f"≥{cfg.max_failures} неудачных входов в UI за {cfg.window_minutes} мин",
|
||||
)
|
||||
)
|
||||
return blocks
|
||||
|
||||
|
||||
def list_ssh_login_blocks() -> list[LoginBlockEntry]:
|
||||
banned = list_ssh_banned_ips()
|
||||
return [
|
||||
LoginBlockEntry(
|
||||
ip_address=ip,
|
||||
scope="ssh",
|
||||
failure_count=None,
|
||||
usernames=(),
|
||||
blocked_until=None,
|
||||
reason="fail2ban (sshd)",
|
||||
)
|
||||
for ip in banned
|
||||
]
|
||||
|
||||
|
||||
def list_all_login_blocks(db: Session) -> list[LoginBlockEntry]:
|
||||
web = {b.ip_address: b for b in list_web_login_blocks(db)}
|
||||
ssh = list_ssh_login_blocks()
|
||||
out = list(web.values())
|
||||
for entry in ssh:
|
||||
if entry.ip_address in web:
|
||||
existing = web[entry.ip_address]
|
||||
out[out.index(existing)] = LoginBlockEntry(
|
||||
ip_address=entry.ip_address,
|
||||
scope="web+ssh",
|
||||
failure_count=existing.failure_count,
|
||||
usernames=existing.usernames,
|
||||
blocked_until=existing.blocked_until,
|
||||
reason=f"{existing.reason}; {entry.reason}",
|
||||
)
|
||||
else:
|
||||
out.append(entry)
|
||||
out.sort(key=lambda e: e.ip_address)
|
||||
return out
|
||||
|
||||
|
||||
def clear_web_login_block(db: Session, ip_address: str) -> int:
|
||||
ip = (ip_address or "").strip()
|
||||
if not ip:
|
||||
return 0
|
||||
result = db.execute(delete(LoginAttempt).where(LoginAttempt.ip_address == ip))
|
||||
db.commit()
|
||||
return int(result.rowcount or 0)
|
||||
|
||||
|
||||
def unblock_login_ip(db: Session, ip_address: str, *, scope: str = "both") -> list[str]:
|
||||
ip = (ip_address or "").strip()
|
||||
if not ip:
|
||||
return ["empty ip"]
|
||||
messages: list[str] = []
|
||||
scope_norm = (scope or "both").strip().lower()
|
||||
if scope_norm in ("web", "both"):
|
||||
deleted = clear_web_login_block(db, ip)
|
||||
messages.append(f"Web UI: удалено записей login_attempts: {deleted}")
|
||||
if scope_norm in ("ssh", "both"):
|
||||
res: Fail2banActionResult = unban_ssh_ip(ip)
|
||||
messages.append(res.message)
|
||||
return messages
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Управление зарегистрированными устройствами Seaca."""
|
||||
"""Управление зарегистрированными устройствами Seaca."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -86,6 +86,13 @@ def revoke_device(db: Session, device_id: int) -> MobileDeviceSummary:
|
||||
return _to_summary(device, username)
|
||||
|
||||
|
||||
def delete_device_record(db: Session, device_id: int) -> None:
|
||||
device = get_device_or_raise(db, device_id)
|
||||
revoke_refresh_tokens_for_device(db, device.id)
|
||||
db.delete(device)
|
||||
db.commit()
|
||||
|
||||
|
||||
def update_fcm_token(db: Session, device: MobileDevice, fcm_token: str) -> None:
|
||||
cleaned = (fcm_token or "").strip()
|
||||
if not cleaned:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Создание и проверка кодов регистрации Seaca."""
|
||||
"""Создание и проверка кодов регистрации Seaca."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -223,31 +223,50 @@ def enroll_device(
|
||||
if code_row.target_user_id is not None and user.id != code_row.target_user_id:
|
||||
raise ValueError("enrollment code is bound to another user")
|
||||
|
||||
existing = db.scalar(select(MobileDevice).where(MobileDevice.device_uuid == cleaned_uuid))
|
||||
if existing is not None and existing.user_id != user.id:
|
||||
raise ValueError("device_uuid already registered to another user")
|
||||
|
||||
active_count = _count_active_devices(db, user.id)
|
||||
if (
|
||||
existing is not None
|
||||
and existing.user_id == user.id
|
||||
and existing.revoked_at is None
|
||||
):
|
||||
active_count -= 1
|
||||
if active_count >= mobile_cfg.max_devices_per_user:
|
||||
raise ValueError("device limit reached for this user")
|
||||
|
||||
existing = db.scalar(select(MobileDevice).where(MobileDevice.device_uuid == cleaned_uuid))
|
||||
if existing is not None:
|
||||
if existing.revoked_at is None and existing.user_id != user.id:
|
||||
raise ValueError("device_uuid already registered to another user")
|
||||
if existing.revoked_at is None:
|
||||
revoke_refresh_tokens_for_device(db, existing.id)
|
||||
existing.revoked_at = now
|
||||
existing = None
|
||||
cleaned_display = (display_name or "").strip() or "Android"
|
||||
cleaned_platform = (platform or "android").strip() or "android"
|
||||
cleaned_app_version = (app_version or "").strip() or None
|
||||
cleaned_fcm = (fcm_token or "").strip() or None
|
||||
|
||||
device = MobileDevice(
|
||||
user_id=user.id,
|
||||
device_uuid=cleaned_uuid,
|
||||
display_name=(display_name or "").strip() or "Android",
|
||||
platform=(platform or "android").strip() or "android",
|
||||
app_version=(app_version or "").strip() or None,
|
||||
fcm_token=(fcm_token or "").strip() or None,
|
||||
fcm_token_updated_at=now if fcm_token else None,
|
||||
enrollment_code_id=code_row.id,
|
||||
last_seen_at=now,
|
||||
)
|
||||
db.add(device)
|
||||
if existing is not None:
|
||||
revoke_refresh_tokens_for_device(db, existing.id)
|
||||
existing.revoked_at = None
|
||||
existing.user_id = user.id
|
||||
existing.display_name = cleaned_display
|
||||
existing.platform = cleaned_platform
|
||||
existing.app_version = cleaned_app_version
|
||||
existing.fcm_token = cleaned_fcm
|
||||
existing.fcm_token_updated_at = now if cleaned_fcm else None
|
||||
existing.enrollment_code_id = code_row.id
|
||||
existing.last_seen_at = now
|
||||
device = existing
|
||||
else:
|
||||
device = MobileDevice(
|
||||
user_id=user.id,
|
||||
device_uuid=cleaned_uuid,
|
||||
display_name=cleaned_display,
|
||||
platform=cleaned_platform,
|
||||
app_version=cleaned_app_version,
|
||||
fcm_token=cleaned_fcm,
|
||||
fcm_token_updated_at=now if cleaned_fcm else None,
|
||||
enrollment_code_id=code_row.id,
|
||||
last_seen_at=now,
|
||||
)
|
||||
db.add(device)
|
||||
code_row.use_count += 1
|
||||
db.flush()
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.models import Event, Problem
|
||||
from app.models.mobile_device import MobileDevice
|
||||
from app.services.notification_severity import severity_meets_minimum
|
||||
from app.services.notification_policy import get_effective_notification_policy
|
||||
from app.services.telegram_templates import format_event_mobile_push
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -170,8 +171,7 @@ def _send_fcm_data(
|
||||
|
||||
|
||||
def _event_payload(event: Event) -> tuple[str, str, dict[str, str]]:
|
||||
title = f"[{event.severity}] {event.title}"
|
||||
body = (event.summary or "")[:200]
|
||||
title, body = format_event_mobile_push(event)
|
||||
data = {
|
||||
"kind": "event",
|
||||
"id": str(event.id),
|
||||
|
||||
@@ -4,16 +4,25 @@ import logging
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Event, Problem
|
||||
from app.models import Event, Host, Problem
|
||||
from app.services import email_notify, mobile_notify, telegram_notify, webhook_notify
|
||||
from app.services.notification_cooldown import should_notify_event, should_notify_problem
|
||||
from app.services.notification_policy import get_effective_notification_policy
|
||||
from app.services.event_type_visibility import event_type_notifications_enabled
|
||||
from app.services.host_health import HEARTBEAT_TYPE
|
||||
from app.services.notification_severity import severity_meets_minimum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
LIFECYCLE_EVENT_TYPE = "agent.lifecycle"
|
||||
PRIVILEGE_SUDO_TYPE = "privilege.sudo.command"
|
||||
SUDO_MAINTENANCE_MARKERS = (
|
||||
"update_ssh_monitor.sh",
|
||||
"update_via_sac",
|
||||
"update_script.log",
|
||||
"/opt/scripts/update",
|
||||
"agent-update-in-progress",
|
||||
)
|
||||
DAILY_REPORT_EVENT_TYPES = frozenset({"report.daily.ssh", "report.daily.rdp"})
|
||||
AUTH_LOGIN_SUCCESS_TYPES = frozenset({"rdp.login.success", "ssh.login.success"})
|
||||
RDG_CONNECTION_TYPES = frozenset({
|
||||
@@ -29,6 +38,17 @@ def _event_telegram_via_agent(event: Event) -> bool:
|
||||
return via == "agent"
|
||||
|
||||
|
||||
def _skip_notifications_for_hidden_event(event: Event, db: Session | None) -> bool:
|
||||
if event_type_notifications_enabled(event.type, db):
|
||||
return False
|
||||
logger.info(
|
||||
"notify skipped hidden event type=%s event_id=%s",
|
||||
event.type,
|
||||
event.event_id,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _dispatch_event_channels(event: Event, *, db: Session | None, policy) -> None:
|
||||
if policy.use_telegram:
|
||||
telegram_notify.notify_event(event, db=db, apply_policy_gate=False)
|
||||
@@ -55,6 +75,10 @@ def notify_event(event: Event, *, db: Session | None = None) -> None:
|
||||
# Heartbeat — только для UI/статуса хоста, не для Telegram/email/push.
|
||||
if event.type == HEARTBEAT_TYPE:
|
||||
return
|
||||
if _should_suppress_sudo_notify(event, db=db):
|
||||
return
|
||||
if _skip_notifications_for_hidden_event(event, db):
|
||||
return
|
||||
policy = get_effective_notification_policy(db)
|
||||
if not severity_meets_minimum(event.severity, policy.min_severity):
|
||||
return
|
||||
@@ -89,14 +113,78 @@ def notify_daily_report(event: Event, *, db: Session | None = None) -> None:
|
||||
|
||||
При UseSAC=dual агент шлёт TG сам (telegram_via=agent) — SAC не дублирует.
|
||||
"""
|
||||
if _skip_notifications_for_hidden_event(event, db):
|
||||
return
|
||||
policy = get_effective_notification_policy(db)
|
||||
if not should_notify_event(event, db):
|
||||
return
|
||||
_dispatch_lifecycle_channels(event, db=db, policy=policy)
|
||||
|
||||
|
||||
def _host_sac_update_running(event: Event, db: Session | None) -> bool:
|
||||
"""Пока SAC выполняет agent-update на хосте — не слать шумные TG."""
|
||||
if db is None or not event.host_id:
|
||||
return False
|
||||
host = db.get(Host, event.host_id)
|
||||
if host is None:
|
||||
return False
|
||||
if (host.agent_update_state or "").strip().lower() == "running":
|
||||
logger.info(
|
||||
"notify skipped (host update running) type=%s host_id=%s event_id=%s",
|
||||
event.type,
|
||||
host.id,
|
||||
event.event_id,
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _sudo_event_is_agent_maintenance(event: Event) -> bool:
|
||||
details = event.details if isinstance(event.details, dict) else {}
|
||||
cmd = str(details.get("command") or "").strip()
|
||||
if not cmd:
|
||||
cmd = str(event.summary or "").strip()
|
||||
text = cmd.casefold()
|
||||
return any(marker in text for marker in SUDO_MAINTENANCE_MARKERS)
|
||||
|
||||
|
||||
def _should_suppress_sudo_notify(event: Event, *, db: Session | None) -> bool:
|
||||
if event.type != PRIVILEGE_SUDO_TYPE:
|
||||
return False
|
||||
if _host_sac_update_running(event, db):
|
||||
return True
|
||||
if _sudo_event_is_agent_maintenance(event):
|
||||
logger.info(
|
||||
"notify sudo skipped maintenance command event_id=%s host_id=%s",
|
||||
event.event_id,
|
||||
event.host_id,
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _lifecycle_suppress_notifications(event: Event, *, db: Session | None = None) -> bool:
|
||||
"""Не слать TG при штатном SAC/cron update (lifecycle с trigger deploy_recycle)."""
|
||||
if _host_sac_update_running(event, db):
|
||||
return True
|
||||
details = event.details if isinstance(event.details, dict) else {}
|
||||
trigger = str(details.get("trigger") or "").strip().lower()
|
||||
if trigger in ("deploy_recycle", "sac_update", "agent_update"):
|
||||
logger.info(
|
||||
"notify lifecycle skipped trigger=%s event_id=%s",
|
||||
trigger,
|
||||
event.event_id,
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def notify_lifecycle(event: Event, *, db: Session | None = None) -> None:
|
||||
"""Старт/стоп/reload агента — всегда в каналы SAC (кроме TG, если telegram_via=agent)."""
|
||||
if _lifecycle_suppress_notifications(event, db=db):
|
||||
return
|
||||
if _skip_notifications_for_hidden_event(event, db):
|
||||
return
|
||||
policy = get_effective_notification_policy(db)
|
||||
if not should_notify_event(event, db):
|
||||
return
|
||||
@@ -108,6 +196,8 @@ def notify_auth_login(event: Event, *, db: Session | None = None) -> None:
|
||||
|
||||
При UseSAC=dual агент шлёт TG сам (telegram_via=agent) — SAC не дублирует.
|
||||
"""
|
||||
if _skip_notifications_for_hidden_event(event, db):
|
||||
return
|
||||
policy = get_effective_notification_policy(db)
|
||||
if not should_notify_event(event, db):
|
||||
return
|
||||
@@ -116,7 +206,40 @@ def notify_auth_login(event: Event, *, db: Session | None = None) -> None:
|
||||
|
||||
def notify_rdg_connection(event: Event, *, db: Session | None = None) -> None:
|
||||
"""RD Gateway 302/303 — ingest всегда; Telegram SAC вне min_severity (как auth login)."""
|
||||
if _skip_notifications_for_hidden_event(event, db):
|
||||
return
|
||||
policy = get_effective_notification_policy(db)
|
||||
if not should_notify_event(event, db):
|
||||
return
|
||||
_dispatch_lifecycle_channels(event, db=db, policy=policy)
|
||||
|
||||
|
||||
def schedule_notify_daily_report(event_db_id: int) -> None:
|
||||
"""Отложенное оповещение по суточному отчёту (после commit ingest, вне горячего POST)."""
|
||||
_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
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
event = db.get(Event, event_db_id)
|
||||
if event is None:
|
||||
logger.warning("deferred %s notify: event id=%s not found", label, event_db_id)
|
||||
return
|
||||
handler(event, db=db)
|
||||
except Exception:
|
||||
logger.exception("deferred %s notify failed event_db_id=%s", label, event_db_id)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -18,6 +18,7 @@ PRIVILEGE_SUDO_TYPE = "privilege.sudo.command"
|
||||
RULE_BRUTE_FORCE = "rule:brute_force_burst"
|
||||
RULE_PRIVILEGE_SPIKE = "rule:privilege_spike"
|
||||
RULE_HOST_SILENCE = "rule:host_silence"
|
||||
RULE_RDG_SESSION_FLAP = "rule:rdg_session_flap"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -204,8 +205,11 @@ def build_fingerprint(host_id: int, correlation_type: str, rule_id: str, suffix:
|
||||
|
||||
|
||||
def pick_rule_match(db: Session, event: Event) -> RuleMatch | None:
|
||||
"""Первое сработавшее правило (приоритет: burst → spike → silence → immediate)."""
|
||||
"""Первое сработавшее правило (приоритет: rdg flap → burst → spike → silence → immediate)."""
|
||||
from app.services.rdg_session_flap import evaluate_rdg_session_flap
|
||||
|
||||
for evaluator in (
|
||||
evaluate_rdg_session_flap,
|
||||
evaluate_brute_force_burst,
|
||||
evaluate_privilege_spike,
|
||||
evaluate_host_silence,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Auto-create Problems from ingested events (rules + correlation)."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -11,6 +11,7 @@ from app.services.host_health import HEARTBEAT_TYPE
|
||||
from app.services.problem_rules import (
|
||||
RuleMatch,
|
||||
RULE_HOST_SILENCE,
|
||||
RULE_RDG_SESSION_FLAP,
|
||||
build_fingerprint,
|
||||
pick_rule_match,
|
||||
resolve_host_silence_problems,
|
||||
@@ -18,8 +19,6 @@ from app.services.problem_rules import (
|
||||
|
||||
|
||||
def _correlation_cutoff(now: datetime) -> datetime:
|
||||
from datetime import timedelta
|
||||
|
||||
minutes = get_settings().sac_problem_correlation_window_minutes
|
||||
return now - timedelta(minutes=minutes)
|
||||
|
||||
@@ -52,7 +51,7 @@ def open_or_append_problem(
|
||||
if ref.tzinfo is None:
|
||||
ref = ref.replace(tzinfo=timezone.utc)
|
||||
problem, created = open_or_refresh_host_silence_problem(
|
||||
db, event.host_id, match, now=ref
|
||||
db, event.host_id, match, now=ref, hostname=event.host.hostname if event.host else None
|
||||
)
|
||||
if problem is None:
|
||||
return None, False
|
||||
@@ -66,7 +65,10 @@ def open_or_append_problem(
|
||||
match.fingerprint_suffix,
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
cutoff = _correlation_cutoff(now)
|
||||
if match.rule_id == RULE_RDG_SESSION_FLAP:
|
||||
cutoff = now - timedelta(seconds=get_settings().sac_rdg_flap_dedup_sec)
|
||||
else:
|
||||
cutoff = _correlation_cutoff(now)
|
||||
|
||||
open_problem = db.scalar(
|
||||
select(Problem)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Resolve RDG client workstation (session host) from event internal_ip."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Event, Host
|
||||
from app.services.rdg_session_flap import event_internal_ip
|
||||
from app.services.winrm_connect import is_windows_host
|
||||
|
||||
|
||||
class ClientWorkstationNotFoundError(Exception):
|
||||
def __init__(self, internal_ip: str) -> None:
|
||||
self.internal_ip = internal_ip
|
||||
super().__init__(
|
||||
f"Client workstation not found in SAC hosts for internal_ip={internal_ip}. "
|
||||
"Ensure the PC is registered with matching ipv4."
|
||||
)
|
||||
|
||||
|
||||
def find_windows_host_by_ipv4(db: Session, ipv4: str) -> Host | None:
|
||||
ip = (ipv4 or "").strip()
|
||||
if not ip:
|
||||
return None
|
||||
rows = db.scalars(
|
||||
select(Host)
|
||||
.where(
|
||||
Host.ipv4 == ip,
|
||||
or_(
|
||||
Host.os_family.ilike("windows"),
|
||||
Host.product == "rdp-login-monitor",
|
||||
),
|
||||
)
|
||||
.order_by(Host.last_seen_at.desc())
|
||||
).all()
|
||||
for host in rows:
|
||||
if is_windows_host(host):
|
||||
return host
|
||||
return None
|
||||
|
||||
|
||||
def resolve_client_workstation(db: Session, event: Event) -> Host:
|
||||
internal_ip = event_internal_ip(event)
|
||||
if not internal_ip:
|
||||
raise ClientWorkstationNotFoundError("")
|
||||
host = find_windows_host_by_ipv4(db, internal_ip)
|
||||
if host is None:
|
||||
raise ClientWorkstationNotFoundError(internal_ip)
|
||||
return host
|
||||
@@ -0,0 +1,132 @@
|
||||
"""RD Gateway event labels (access path, UI title/summary)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import Event
|
||||
from app.services.rdg_client_host import find_windows_host_by_ipv4
|
||||
from app.services.rdg_session_flap import event_internal_ip, resolve_rdg_qwinsta_enabled
|
||||
|
||||
RDG_TYPES = frozenset(
|
||||
{
|
||||
"rdg.connection.success",
|
||||
"rdg.connection.disconnected",
|
||||
"rdg.connection.failed",
|
||||
}
|
||||
)
|
||||
|
||||
ACCESS_PATH_HAPROXY = "Haproxy-RDG-Comp"
|
||||
ACCESS_PATH_DIRECT = "RDG-Comp"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RdgDisplayInfo:
|
||||
title: str
|
||||
summary: str
|
||||
access_path: str | None
|
||||
internal_ip: str | None
|
||||
qwinsta_enabled: bool
|
||||
|
||||
|
||||
def _details_dict(event: Event) -> dict:
|
||||
raw = event.details
|
||||
return raw if isinstance(raw, dict) else {}
|
||||
|
||||
|
||||
def _event_external_ip(event: Event) -> str:
|
||||
details = _details_dict(event)
|
||||
for key in ("external_ip", "source_ip", "ip_address"):
|
||||
val = details.get(key)
|
||||
if val is not None and str(val).strip():
|
||||
return str(val).strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _parse_haproxy_ips() -> frozenset[str]:
|
||||
raw = (get_settings().sac_rdg_haproxy_external_ips or "").strip()
|
||||
if not raw:
|
||||
return frozenset()
|
||||
parts = [p.strip() for p in raw.replace(";", ",").split(",")]
|
||||
return frozenset(p for p in parts if p)
|
||||
|
||||
|
||||
def classify_rdg_access_path(external_ip: str) -> str | None:
|
||||
ip = (external_ip or "").strip()
|
||||
if not ip or ip in ("-", "N/A"):
|
||||
return None
|
||||
if ip in _parse_haproxy_ips():
|
||||
return ACCESS_PATH_HAPROXY
|
||||
return ACCESS_PATH_DIRECT
|
||||
|
||||
|
||||
def _windows_event_label(event: Event) -> str:
|
||||
details = _details_dict(event)
|
||||
win_id = details.get("event_id_windows")
|
||||
if win_id is None:
|
||||
return ""
|
||||
text = str(win_id).strip()
|
||||
return f"event {text}" if text else ""
|
||||
|
||||
|
||||
def _action_label(event: Event) -> str:
|
||||
if event.type == "rdg.connection.success":
|
||||
return "подключение"
|
||||
if event.type == "rdg.connection.disconnected":
|
||||
return "отключение"
|
||||
return "ошибка"
|
||||
|
||||
|
||||
def build_rdg_display(event: Event, db: Session | None = None) -> RdgDisplayInfo | None:
|
||||
if event.type not in RDG_TYPES:
|
||||
return None
|
||||
|
||||
details = _details_dict(event)
|
||||
internal_ip = event_internal_ip(event)
|
||||
external_ip = _event_external_ip(event)
|
||||
access_path = classify_rdg_access_path(external_ip)
|
||||
user = str(details.get("user") or "").strip()
|
||||
gateway = event.host.hostname if event.host else "—"
|
||||
|
||||
client_label = internal_ip or "—"
|
||||
if db and internal_ip:
|
||||
client_host = find_windows_host_by_ipv4(db, internal_ip)
|
||||
if client_host is not None:
|
||||
client_label = f"{client_host.hostname} ({internal_ip})"
|
||||
|
||||
path_note = f" ({access_path})" if access_path else ""
|
||||
win_note = _windows_event_label(event)
|
||||
action = _action_label(event)
|
||||
|
||||
title = f"RDS {action} → {client_label}{path_note}"
|
||||
if win_note:
|
||||
title = f"{title} · {win_note}"
|
||||
|
||||
summary_parts: list[str] = []
|
||||
if user:
|
||||
summary_parts.append(user)
|
||||
if external_ip:
|
||||
summary_parts.append(f"внешний {external_ip}")
|
||||
summary_parts.append(f"шлюз {gateway}")
|
||||
if win_note:
|
||||
summary_parts.append(win_note)
|
||||
summary = " · ".join(summary_parts)
|
||||
|
||||
qwinsta_enabled = resolve_rdg_qwinsta_enabled(db, event)
|
||||
|
||||
return RdgDisplayInfo(
|
||||
title=title,
|
||||
summary=summary,
|
||||
access_path=access_path,
|
||||
internal_ip=internal_ip or None,
|
||||
qwinsta_enabled=qwinsta_enabled,
|
||||
)
|
||||
|
||||
|
||||
def event_supports_rdg_client_qwinsta(event: Event, db: Session | None = None) -> bool:
|
||||
from app.services.rdg_session_flap import resolve_rdg_qwinsta_enabled
|
||||
|
||||
return resolve_rdg_qwinsta_enabled(db, event)
|
||||
@@ -0,0 +1,311 @@
|
||||
"""RDG 302→303 session flap: detect, flag event, build Problem match."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import Event
|
||||
from app.services.problem_rules import RULE_RDG_SESSION_FLAP, RuleMatch
|
||||
|
||||
RDG_SUCCESS_TYPE = "rdg.connection.success"
|
||||
RDG_END_TYPES = frozenset({"rdg.connection.disconnected", "rdg.connection.failed"})
|
||||
|
||||
|
||||
def _event_user(event: Event) -> str:
|
||||
details = event.details if isinstance(event.details, dict) else {}
|
||||
user = details.get("user")
|
||||
return str(user).strip() if user else ""
|
||||
|
||||
|
||||
def _event_internal_ip(event: Event) -> str:
|
||||
details = event.details if isinstance(event.details, dict) else {}
|
||||
for key in ("internal_ip", "client_ip", "ip_address"):
|
||||
val = details.get(key)
|
||||
if val:
|
||||
return str(val).strip()
|
||||
return ""
|
||||
|
||||
|
||||
def event_internal_ip(event: Event) -> str:
|
||||
return _event_internal_ip(event)
|
||||
|
||||
|
||||
def _users_match(end_event: Event, success_event: Event) -> bool:
|
||||
return _event_user(end_event) != "" and _event_user(end_event) == _event_user(success_event)
|
||||
|
||||
|
||||
def _internal_ips_compatible(end_event: Event, success_event: Event) -> bool:
|
||||
end_ip = _event_internal_ip(end_event)
|
||||
success_ip = _event_internal_ip(success_event)
|
||||
if not end_ip or not success_ip:
|
||||
return True
|
||||
return end_ip == success_ip
|
||||
|
||||
|
||||
def _internal_ips_match_strict(end_event: Event, success_event: Event) -> bool:
|
||||
"""Для «сессия завершена» — только при совпадении целевого ПК (оба IP заданы)."""
|
||||
end_ip = _event_internal_ip(end_event)
|
||||
success_ip = _event_internal_ip(success_event)
|
||||
if not end_ip or not success_ip:
|
||||
return False
|
||||
return end_ip == success_ip
|
||||
|
||||
|
||||
def _as_utc(dt: datetime) -> datetime:
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def find_rdg_success_before_end(db: Session, end_event: Event) -> Event | None:
|
||||
if end_event.type not in RDG_END_TYPES:
|
||||
return None
|
||||
settings = get_settings()
|
||||
min_sec = settings.sac_rdg_flap_window_min_sec
|
||||
max_sec = settings.sac_rdg_flap_window_max_sec
|
||||
end_at = _as_utc(end_event.occurred_at)
|
||||
|
||||
window_start = end_at - timedelta(seconds=max_sec)
|
||||
window_end = end_at - timedelta(seconds=min_sec)
|
||||
|
||||
candidates = db.scalars(
|
||||
select(Event)
|
||||
.where(
|
||||
Event.host_id == end_event.host_id,
|
||||
Event.type == RDG_SUCCESS_TYPE,
|
||||
Event.occurred_at >= window_start,
|
||||
Event.occurred_at <= window_end,
|
||||
Event.id != end_event.id,
|
||||
)
|
||||
.order_by(Event.occurred_at.desc())
|
||||
).all()
|
||||
|
||||
for prior in candidates:
|
||||
if not _users_match(end_event, prior):
|
||||
continue
|
||||
if not _internal_ips_compatible(end_event, prior):
|
||||
continue
|
||||
prior_at = _as_utc(prior.occurred_at)
|
||||
delta = (end_at - prior_at).total_seconds()
|
||||
if min_sec <= delta <= max_sec:
|
||||
return prior
|
||||
return None
|
||||
|
||||
|
||||
def mark_rdg_flap(event: Event, *, pair_event: Event) -> None:
|
||||
details = dict(event.details) if isinstance(event.details, dict) else {}
|
||||
details["rdg_flap"] = True
|
||||
details["rdg_flap_pair_event_id"] = pair_event.id
|
||||
event.details = details
|
||||
flag_modified(event, "details")
|
||||
|
||||
|
||||
def evaluate_rdg_session_flap(db: Session, event: Event) -> RuleMatch | None:
|
||||
prior = find_rdg_success_before_end(db, event)
|
||||
if prior is None:
|
||||
return None
|
||||
|
||||
mark_rdg_flap(event, pair_event=prior)
|
||||
user = _event_user(event)
|
||||
internal_ip = _event_internal_ip(event)
|
||||
ip_note = f", client {internal_ip}" if internal_ip else ""
|
||||
delta_sec = int((_as_utc(event.occurred_at) - _as_utc(prior.occurred_at)).total_seconds())
|
||||
return RuleMatch(
|
||||
rule_id=RULE_RDG_SESSION_FLAP,
|
||||
correlation_type="rdg.session.flap",
|
||||
fingerprint_suffix=f"u{user}:ip{internal_ip or 'any'}",
|
||||
title=f"RDG session flap: {user}",
|
||||
summary=(
|
||||
f"302→303 за {delta_sec} с "
|
||||
f"({user}{ip_note}). Возможна зависшая сессия на ПК пользователя — qwinsta/logoff."
|
||||
),
|
||||
severity="warning",
|
||||
)
|
||||
|
||||
|
||||
def event_has_rdg_flap(event: Event) -> bool:
|
||||
details = event.details if isinstance(event.details, dict) else {}
|
||||
return details.get("rdg_flap") is True
|
||||
|
||||
|
||||
def _stored_flap_pair_id(event: Event) -> int | None:
|
||||
details = event.details if isinstance(event.details, dict) else {}
|
||||
raw = details.get("rdg_flap_pair_event_id")
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def find_rdg_end_after_success(db: Session, success_event: Event) -> Event | None:
|
||||
"""303 с rdg_flap, у которого пара — этот 302 (или вычисляется по окну)."""
|
||||
if success_event.type != RDG_SUCCESS_TYPE:
|
||||
return None
|
||||
settings = get_settings()
|
||||
min_sec = settings.sac_rdg_flap_window_min_sec
|
||||
max_sec = settings.sac_rdg_flap_window_max_sec
|
||||
start_at = _as_utc(success_event.occurred_at)
|
||||
window_start = start_at + timedelta(seconds=min_sec)
|
||||
window_end = start_at + timedelta(seconds=max_sec)
|
||||
|
||||
candidates = db.scalars(
|
||||
select(Event)
|
||||
.where(
|
||||
Event.host_id == success_event.host_id,
|
||||
Event.type.in_(RDG_END_TYPES),
|
||||
Event.occurred_at >= window_start,
|
||||
Event.occurred_at <= window_end,
|
||||
Event.id != success_event.id,
|
||||
)
|
||||
.order_by(Event.occurred_at.asc())
|
||||
).all()
|
||||
|
||||
for end in candidates:
|
||||
if not _users_match(end, success_event):
|
||||
continue
|
||||
if not _internal_ips_compatible(end, success_event):
|
||||
continue
|
||||
pair_id = _stored_flap_pair_id(end)
|
||||
if pair_id == success_event.id:
|
||||
return end
|
||||
prior = find_rdg_success_before_end(db, end)
|
||||
if prior is not None and prior.id == success_event.id:
|
||||
return end
|
||||
return None
|
||||
|
||||
|
||||
def find_normal_rdg_end_after_success(db: Session, success_event: Event) -> Event | None:
|
||||
"""303 после 302 с паузой больше flap-окна — штатное завершение сессии."""
|
||||
if success_event.type != RDG_SUCCESS_TYPE:
|
||||
return None
|
||||
settings = get_settings()
|
||||
max_sec = settings.sac_rdg_flap_window_max_sec
|
||||
start_at = _as_utc(success_event.occurred_at)
|
||||
after_flap = start_at + timedelta(seconds=max_sec)
|
||||
|
||||
candidates = db.scalars(
|
||||
select(Event)
|
||||
.where(
|
||||
Event.host_id == success_event.host_id,
|
||||
Event.type.in_(RDG_END_TYPES),
|
||||
Event.occurred_at > after_flap,
|
||||
Event.id != success_event.id,
|
||||
)
|
||||
.order_by(Event.occurred_at.asc())
|
||||
).all()
|
||||
|
||||
for end in candidates:
|
||||
if not _users_match(end, success_event):
|
||||
continue
|
||||
if not _internal_ips_match_strict(end, success_event):
|
||||
continue
|
||||
return end
|
||||
return None
|
||||
|
||||
|
||||
def _users_and_ip_match(left: Event, right: Event) -> bool:
|
||||
return _users_match(left, right) and _internal_ips_match_strict(left, right)
|
||||
|
||||
|
||||
def find_later_rdg_success_after(
|
||||
db: Session,
|
||||
*,
|
||||
anchor: Event,
|
||||
after: datetime,
|
||||
) -> Event | None:
|
||||
"""Поздний 302 на том же шлюзе, user и client PC — пользователь снова зашёл через RDG."""
|
||||
if anchor.type != RDG_SUCCESS_TYPE:
|
||||
return None
|
||||
after_at = _as_utc(after)
|
||||
|
||||
candidates = db.scalars(
|
||||
select(Event)
|
||||
.where(
|
||||
Event.host_id == anchor.host_id,
|
||||
Event.type == RDG_SUCCESS_TYPE,
|
||||
Event.occurred_at > after_at,
|
||||
Event.id != anchor.id,
|
||||
)
|
||||
.order_by(Event.occurred_at.asc())
|
||||
).all()
|
||||
|
||||
for later in candidates:
|
||||
if not _users_and_ip_match(later, anchor):
|
||||
continue
|
||||
return later
|
||||
return None
|
||||
|
||||
|
||||
def _flap_auto_disconnect_succeeded(flap_end: Event) -> bool:
|
||||
details = flap_end.details if isinstance(flap_end.details, dict) else {}
|
||||
block = details.get("rdp_flap_auto_disconnect")
|
||||
if not isinstance(block, dict):
|
||||
return False
|
||||
return block.get("ok") is True
|
||||
|
||||
|
||||
def _flap_workstation_session_closed(db: Session, flap_end: Event) -> bool:
|
||||
from app.services.host_sessions import event_session_terminated
|
||||
from app.services.rdg_workstation_session import find_workstation_login_for_rdg_end
|
||||
|
||||
login = find_workstation_login_for_rdg_end(db, flap_end)
|
||||
if login is None:
|
||||
return False
|
||||
return event_session_terminated(login, db=db)
|
||||
|
||||
|
||||
def resolve_rdg_qwinsta_enabled(db: Session | None, event: Event) -> bool:
|
||||
"""Кнопка qwinsta/logoff только на 302, пока сессия может быть активна (или RDG flap)."""
|
||||
if event.type in RDG_END_TYPES:
|
||||
return False
|
||||
if event.type != RDG_SUCCESS_TYPE:
|
||||
return False
|
||||
if not _event_internal_ip(event):
|
||||
return False
|
||||
if db is None:
|
||||
return True
|
||||
flap_end = find_rdg_end_after_success(db, event)
|
||||
if flap_end is not None:
|
||||
if _flap_auto_disconnect_succeeded(flap_end):
|
||||
return False
|
||||
if _flap_workstation_session_closed(db, flap_end):
|
||||
return False
|
||||
if find_later_rdg_success_after(db, anchor=event, after=flap_end.occurred_at) is not None:
|
||||
return False
|
||||
return True
|
||||
if find_normal_rdg_end_after_success(db, event) is not None:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def resolve_rdg_flap_summary(
|
||||
db: Session, event: Event
|
||||
) -> tuple[bool, int | None, int | None]:
|
||||
"""
|
||||
(rdg_flap, pair_event_id, qwinsta_event_id).
|
||||
qwinsta_event_id — id события 302 для qwinsta (на 303 кнопку не показываем).
|
||||
"""
|
||||
if event_has_rdg_flap(event):
|
||||
pair_id = _stored_flap_pair_id(event)
|
||||
if event.type == RDG_SUCCESS_TYPE:
|
||||
return True, pair_id, event.id
|
||||
return True, pair_id, pair_id
|
||||
|
||||
if event.type in RDG_END_TYPES:
|
||||
prior = find_rdg_success_before_end(db, event)
|
||||
if prior is not None:
|
||||
return True, prior.id, prior.id
|
||||
|
||||
if event.type == RDG_SUCCESS_TYPE:
|
||||
end = find_rdg_end_after_success(db, event)
|
||||
if end is not None:
|
||||
return True, end.id, event.id
|
||||
|
||||
return False, None, None
|
||||
@@ -0,0 +1,182 @@
|
||||
"""RDG qwinsta/logoff via WinRM on client workstation (variant B)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import AgentCommand, Event, Host
|
||||
from app.services.rdg_client_host import ClientWorkstationNotFoundError, resolve_client_workstation
|
||||
from app.services.rdg_display import event_supports_rdg_client_qwinsta
|
||||
from app.services.rdg_session_flap import event_internal_ip
|
||||
from app.services.win_admin_settings import get_effective_win_admin_for_host
|
||||
from app.services.winrm_connect import (
|
||||
WinRmCmdResult,
|
||||
run_winrm_logoff,
|
||||
run_winrm_on_host_targets,
|
||||
run_winrm_qwinsta,
|
||||
)
|
||||
|
||||
|
||||
def _require_win_admin(db: Session, host: Host | None = None):
|
||||
if host is not None:
|
||||
cfg = get_effective_win_admin_for_host(db, host)
|
||||
else:
|
||||
from app.services.win_admin_settings import get_effective_win_admin_config
|
||||
|
||||
cfg = get_effective_win_admin_config(db)
|
||||
if not cfg.configured:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Windows admin is not configured (host override or Settings → Windows)",
|
||||
)
|
||||
return cfg
|
||||
|
||||
|
||||
def _require_rdg_client_qwinsta(db: Session, event: Event) -> None:
|
||||
if event_supports_rdg_client_qwinsta(event, db):
|
||||
return
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Event is not an RD Gateway connection with internal_ip (client workstation)",
|
||||
)
|
||||
|
||||
|
||||
def _resolve_client(db: Session, event: Event) -> Host:
|
||||
try:
|
||||
return resolve_client_workstation(db, event)
|
||||
except ClientWorkstationNotFoundError as exc:
|
||||
if not exc.internal_ip:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Event has no internal_ip (client workstation address)",
|
||||
) from exc
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
def _format_result_message(result: WinRmCmdResult, *, attempts: list[str]) -> str:
|
||||
message = result.message
|
||||
if attempts:
|
||||
message = f"{message} (пробовали: {', '.join(attempts)})"
|
||||
return message
|
||||
|
||||
|
||||
def _persist_command(
|
||||
db: Session,
|
||||
*,
|
||||
client_host: Host,
|
||||
event: Event,
|
||||
command_type: str,
|
||||
params: dict,
|
||||
requested_by: str,
|
||||
result: WinRmCmdResult,
|
||||
attempts: list[str],
|
||||
) -> AgentCommand:
|
||||
now = datetime.now(timezone.utc)
|
||||
cmd = AgentCommand(
|
||||
command_uuid=str(uuid.uuid4()),
|
||||
host_id=client_host.id,
|
||||
event_id=event.id,
|
||||
command_type=command_type,
|
||||
params=params,
|
||||
status="completed" if result.ok else "failed",
|
||||
result_stdout=result.stdout or None,
|
||||
result_stderr=(result.stderr or _format_result_message(result, attempts=attempts) or None)
|
||||
if not result.ok
|
||||
else None,
|
||||
requested_by=requested_by,
|
||||
created_at=now,
|
||||
completed_at=now,
|
||||
)
|
||||
db.add(cmd)
|
||||
db.flush()
|
||||
return cmd
|
||||
|
||||
|
||||
def execute_qwinsta_via_winrm(db: Session, event: Event, *, requested_by: str) -> AgentCommand:
|
||||
_require_rdg_client_qwinsta(db, event)
|
||||
client_host = _resolve_client(db, event)
|
||||
cfg = _require_win_admin(db, client_host)
|
||||
|
||||
details = event.details if isinstance(event.details, dict) else {}
|
||||
user = details.get("user")
|
||||
internal_ip = event_internal_ip(event)
|
||||
|
||||
result, attempts = run_winrm_on_host_targets(
|
||||
client_host,
|
||||
user=cfg.user,
|
||||
password=cfg.password,
|
||||
action=lambda target: run_winrm_qwinsta(target=target, user=cfg.user, password=cfg.password),
|
||||
)
|
||||
assert result is not None
|
||||
|
||||
params = {
|
||||
"execution": "winrm",
|
||||
"user": user,
|
||||
"internal_ip": internal_ip,
|
||||
"client_hostname": client_host.hostname,
|
||||
"client_host_id": client_host.id,
|
||||
"winrm_target": result.target,
|
||||
}
|
||||
return _persist_command(
|
||||
db,
|
||||
client_host=client_host,
|
||||
event=event,
|
||||
command_type="qwinsta",
|
||||
params=params,
|
||||
requested_by=requested_by,
|
||||
result=result,
|
||||
attempts=attempts,
|
||||
)
|
||||
|
||||
|
||||
def execute_logoff_via_winrm(
|
||||
db: Session,
|
||||
event: Event,
|
||||
*,
|
||||
session_id: int,
|
||||
requested_by: str,
|
||||
) -> AgentCommand:
|
||||
_require_rdg_client_qwinsta(db, event)
|
||||
client_host = _resolve_client(db, event)
|
||||
cfg = _require_win_admin(db, client_host)
|
||||
|
||||
details = event.details if isinstance(event.details, dict) else {}
|
||||
user = details.get("user")
|
||||
internal_ip = event_internal_ip(event)
|
||||
|
||||
result, attempts = run_winrm_on_host_targets(
|
||||
client_host,
|
||||
user=cfg.user,
|
||||
password=cfg.password,
|
||||
action=lambda target: run_winrm_logoff(
|
||||
target=target,
|
||||
user=cfg.user,
|
||||
password=cfg.password,
|
||||
session_id=session_id,
|
||||
),
|
||||
)
|
||||
assert result is not None
|
||||
|
||||
params = {
|
||||
"execution": "winrm",
|
||||
"user": user,
|
||||
"internal_ip": internal_ip,
|
||||
"client_hostname": client_host.hostname,
|
||||
"client_host_id": client_host.id,
|
||||
"session_id": session_id,
|
||||
"winrm_target": result.target,
|
||||
}
|
||||
return _persist_command(
|
||||
db,
|
||||
client_host=client_host,
|
||||
event=event,
|
||||
command_type="logoff",
|
||||
params=params,
|
||||
requested_by=requested_by,
|
||||
result=result,
|
||||
attempts=attempts,
|
||||
)
|
||||
@@ -0,0 +1,290 @@
|
||||
"""Correlate RDG 303/303-failed with workstation rdp.login.success (1149)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from app.models import Event
|
||||
from app.services.host_sessions import (
|
||||
SESSION_TERMINATED_AT_KEY,
|
||||
_details_dict,
|
||||
_event_login_user,
|
||||
)
|
||||
from app.services.rdg_client_host import find_windows_host_by_ipv4
|
||||
from app.services.rdg_session_flap import (
|
||||
RDG_END_TYPES,
|
||||
RDG_SUCCESS_TYPE,
|
||||
_event_user,
|
||||
event_internal_ip,
|
||||
find_rdg_success_before_end,
|
||||
)
|
||||
|
||||
SESSION_CLOSED_BY_RDG_AT_KEY = "session_closed_by_rdg_at"
|
||||
SESSION_CLOSED_BY_RDG_EVENT_ID_KEY = "session_closed_by_rdg_event_id"
|
||||
USER_ENRICHED_FROM_RDG_EVENT_ID_KEY = "user_enriched_from_rdg_event_id"
|
||||
|
||||
# RCM 1149 via RD Gateway often has empty Param1/Param2; RDG 302 has the account.
|
||||
RDG_LOGIN_USER_ENRICH_WINDOW = timedelta(minutes=5)
|
||||
|
||||
WORKSTATION_LOGIN_TYPE = "rdp.login.success"
|
||||
|
||||
|
||||
def _as_utc(dt):
|
||||
if dt is None:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def normalize_sam_account(user: str) -> str:
|
||||
text = (user or "").strip()
|
||||
if "\\" in text:
|
||||
return text.split("\\")[-1].strip().casefold()
|
||||
if "@" in text:
|
||||
return text.split("@")[0].strip().casefold()
|
||||
return text.casefold()
|
||||
|
||||
|
||||
def users_match_rdg(login_user: str, rdg_user: str) -> bool:
|
||||
left = normalize_sam_account(login_user)
|
||||
right = normalize_sam_account(rdg_user)
|
||||
return bool(left and right and left == right)
|
||||
|
||||
|
||||
def _login_user_missing(event: Event) -> bool:
|
||||
details = _details_dict(event)
|
||||
for key in ("user", "username"):
|
||||
val = details.get(key)
|
||||
if val is not None and str(val).strip() not in ("", "-"):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _apply_rdg_user_to_login(login_event: Event, *, rdg_event: Event, rdg_user: str) -> None:
|
||||
details = dict(_details_dict(login_event))
|
||||
details["user"] = rdg_user
|
||||
details[USER_ENRICHED_FROM_RDG_EVENT_ID_KEY] = rdg_event.id
|
||||
login_event.details = details
|
||||
flag_modified(login_event, "details")
|
||||
summary = (login_event.summary or "").strip()
|
||||
if summary.startswith("RCM 1149") and rdg_user not in summary:
|
||||
rest = summary[len("RCM 1149") :].strip()
|
||||
login_event.summary = f"RCM 1149 {rdg_user} {rest}".strip()
|
||||
|
||||
|
||||
def find_rdg_success_for_workstation_login(db: Session, login_event: Event) -> Event | None:
|
||||
"""Nearest RDG 302 for this workstation IP within the enrich window."""
|
||||
if login_event.type != WORKSTATION_LOGIN_TYPE:
|
||||
return None
|
||||
host = login_event.host
|
||||
if host is None or not (host.ipv4 or "").strip():
|
||||
return None
|
||||
workstation_ip = host.ipv4.strip()
|
||||
login_at = _as_utc(login_event.occurred_at)
|
||||
window_start = login_at - RDG_LOGIN_USER_ENRICH_WINDOW
|
||||
window_end = login_at + RDG_LOGIN_USER_ENRICH_WINDOW
|
||||
|
||||
candidates = db.scalars(
|
||||
select(Event)
|
||||
.where(
|
||||
Event.type == RDG_SUCCESS_TYPE,
|
||||
Event.occurred_at >= window_start,
|
||||
Event.occurred_at <= window_end,
|
||||
Event.id != login_event.id,
|
||||
)
|
||||
.order_by(Event.occurred_at.desc())
|
||||
).all()
|
||||
|
||||
best: Event | None = None
|
||||
best_delta: timedelta | None = None
|
||||
for rdg in candidates:
|
||||
if event_internal_ip(rdg) != workstation_ip:
|
||||
continue
|
||||
if not _event_user(rdg):
|
||||
continue
|
||||
delta = abs(_as_utc(rdg.occurred_at) - login_at)
|
||||
if best is None or best_delta is None or delta < best_delta:
|
||||
best = rdg
|
||||
best_delta = delta
|
||||
return best
|
||||
|
||||
|
||||
def enrich_workstation_login_user_from_rdg(db: Session, login_event: Event) -> Event | None:
|
||||
"""Fill details.user when RCM 1149 EventLog left Param1/Param2 empty (seen on some Win10 Pro)."""
|
||||
if login_event.type != WORKSTATION_LOGIN_TYPE:
|
||||
return None
|
||||
if not _login_user_missing(login_event):
|
||||
return None
|
||||
rdg = find_rdg_success_for_workstation_login(db, login_event)
|
||||
if rdg is None:
|
||||
return None
|
||||
rdg_user = _event_user(rdg)
|
||||
if not rdg_user:
|
||||
return None
|
||||
_apply_rdg_user_to_login(login_event, rdg_event=rdg, rdg_user=rdg_user)
|
||||
return login_event
|
||||
|
||||
|
||||
def enrich_empty_login_from_rdg_success(db: Session, rdg_success_event: Event) -> Event | None:
|
||||
"""Backfill empty workstation 1149 when RDG 302 is ingested after it."""
|
||||
if rdg_success_event.type != RDG_SUCCESS_TYPE:
|
||||
return None
|
||||
internal_ip = event_internal_ip(rdg_success_event)
|
||||
rdg_user = _event_user(rdg_success_event)
|
||||
if not internal_ip or not rdg_user:
|
||||
return None
|
||||
client_host = find_windows_host_by_ipv4(db, internal_ip)
|
||||
if client_host is None:
|
||||
return None
|
||||
|
||||
rdg_at = _as_utc(rdg_success_event.occurred_at)
|
||||
window_start = rdg_at - RDG_LOGIN_USER_ENRICH_WINDOW
|
||||
window_end = rdg_at + RDG_LOGIN_USER_ENRICH_WINDOW
|
||||
candidates = db.scalars(
|
||||
select(Event)
|
||||
.where(
|
||||
Event.host_id == client_host.id,
|
||||
Event.type == WORKSTATION_LOGIN_TYPE,
|
||||
Event.occurred_at >= window_start,
|
||||
Event.occurred_at <= window_end,
|
||||
Event.id != rdg_success_event.id,
|
||||
)
|
||||
.order_by(Event.occurred_at.desc())
|
||||
).all()
|
||||
|
||||
for login in candidates:
|
||||
if not _login_user_missing(login):
|
||||
continue
|
||||
_apply_rdg_user_to_login(login, rdg_event=rdg_success_event, rdg_user=rdg_user)
|
||||
return login
|
||||
return None
|
||||
|
||||
|
||||
def event_closed_by_rdg(event: Event) -> bool:
|
||||
details = _details_dict(event)
|
||||
at = details.get(SESSION_CLOSED_BY_RDG_AT_KEY)
|
||||
return at is not None and str(at).strip() != ""
|
||||
|
||||
|
||||
def mark_login_closed_by_rdg(login_event: Event, *, rdg_end_event: Event) -> None:
|
||||
details = dict(_details_dict(login_event))
|
||||
details[SESSION_CLOSED_BY_RDG_AT_KEY] = rdg_end_event.occurred_at.isoformat()
|
||||
details[SESSION_CLOSED_BY_RDG_EVENT_ID_KEY] = rdg_end_event.id
|
||||
login_event.details = details
|
||||
flag_modified(login_event, "details")
|
||||
|
||||
|
||||
def _login_already_closed(login_event: Event) -> bool:
|
||||
from app.services.rdp_session_logoff import event_closed_by_logoff
|
||||
|
||||
details = _details_dict(login_event)
|
||||
if details.get("session_terminated") is True:
|
||||
return True
|
||||
at = details.get(SESSION_TERMINATED_AT_KEY)
|
||||
if at is not None and str(at).strip() != "":
|
||||
return True
|
||||
if event_closed_by_rdg(login_event):
|
||||
return True
|
||||
return event_closed_by_logoff(login_event)
|
||||
|
||||
|
||||
def find_workstation_login_for_rdg_end(db: Session, rdg_end_event: Event) -> Event | None:
|
||||
if rdg_end_event.type not in RDG_END_TYPES:
|
||||
return None
|
||||
internal_ip = event_internal_ip(rdg_end_event)
|
||||
if not internal_ip:
|
||||
return None
|
||||
client_host = find_windows_host_by_ipv4(db, internal_ip)
|
||||
if client_host is None:
|
||||
return None
|
||||
|
||||
rdg_user = _event_user(rdg_end_event)
|
||||
if not rdg_user:
|
||||
return None
|
||||
end_at = rdg_end_event.occurred_at
|
||||
|
||||
candidates = db.scalars(
|
||||
select(Event)
|
||||
.where(
|
||||
Event.host_id == client_host.id,
|
||||
Event.type == WORKSTATION_LOGIN_TYPE,
|
||||
Event.occurred_at <= end_at,
|
||||
Event.id != rdg_end_event.id,
|
||||
)
|
||||
.order_by(Event.occurred_at.desc())
|
||||
).all()
|
||||
|
||||
for login in candidates:
|
||||
if _login_already_closed(login):
|
||||
continue
|
||||
login_user = (_event_login_user(login) or "").strip()
|
||||
if login_user in ("", "-"):
|
||||
login_user = ""
|
||||
# RCM 1149 may lack user (empty Param1); still close by workstation IP + open session.
|
||||
if login_user and not users_match_rdg(login_user, rdg_user):
|
||||
continue
|
||||
return login
|
||||
return None
|
||||
|
||||
|
||||
def find_rdg_end_after_workstation_login(db: Session, login_event: Event) -> Event | None:
|
||||
"""Runtime lookup for historical events without persisted close flag."""
|
||||
if login_event.type != WORKSTATION_LOGIN_TYPE:
|
||||
return None
|
||||
if _login_already_closed(login_event):
|
||||
return None
|
||||
|
||||
host = login_event.host
|
||||
if host is None or not host.ipv4:
|
||||
return None
|
||||
|
||||
workstation_ip = host.ipv4.strip()
|
||||
login_user = _event_login_user(login_event)
|
||||
if not login_user:
|
||||
return None
|
||||
login_at = login_event.occurred_at
|
||||
|
||||
candidates = db.scalars(
|
||||
select(Event)
|
||||
.where(
|
||||
Event.type.in_(RDG_END_TYPES),
|
||||
Event.occurred_at >= login_at,
|
||||
Event.id != login_event.id,
|
||||
)
|
||||
.order_by(Event.occurred_at.asc())
|
||||
).all()
|
||||
|
||||
for end in candidates:
|
||||
if event_internal_ip(end) != workstation_ip:
|
||||
continue
|
||||
if not users_match_rdg(_event_user(end), login_user):
|
||||
continue
|
||||
if find_rdg_success_before_end(db, end) is not None:
|
||||
continue
|
||||
return end
|
||||
return None
|
||||
|
||||
|
||||
def resolve_workstation_login_closed(db: Session, login_event: Event) -> bool:
|
||||
if event_closed_by_rdg(login_event):
|
||||
return True
|
||||
return find_rdg_end_after_workstation_login(db, login_event) is not None
|
||||
|
||||
|
||||
def close_workstation_session_for_rdg_end(db: Session, rdg_end_event: Event) -> Event | None:
|
||||
"""On RDG disconnect, mark matching workstation login as session-closed."""
|
||||
if rdg_end_event.type not in RDG_END_TYPES:
|
||||
return None
|
||||
if find_rdg_success_before_end(db, rdg_end_event) is not None:
|
||||
return None
|
||||
|
||||
login = find_workstation_login_for_rdg_end(db, rdg_end_event)
|
||||
if login is None:
|
||||
return None
|
||||
mark_login_closed_by_rdg(login, rdg_end_event=rdg_end_event)
|
||||
return login
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Short-lived RDP agent bundles for WinRM clients to download."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
_UTF8_BOM = b"\xef\xbb\xbf"
|
||||
_BOM_SUFFIXES = {".ps1", ".txt"}
|
||||
_TOKEN_RE = re.compile(r"^[A-Za-z0-9_-]{16,128}$")
|
||||
TTL_SECONDS = 600
|
||||
BUNDLE_DIR = Path("/tmp/sac-rdp-bundles")
|
||||
|
||||
|
||||
def _bundle_file_bytes(path: Path) -> bytes:
|
||||
data = path.read_bytes()
|
||||
if path.suffix.lower() in _BOM_SUFFIXES and not data.startswith(_UTF8_BOM):
|
||||
return _UTF8_BOM + data
|
||||
return data
|
||||
|
||||
|
||||
def build_rdp_bundle_zip(repo_dir: Path, filenames: tuple[str, ...]) -> bytes:
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||
for name in filenames:
|
||||
path = repo_dir / name
|
||||
if path.is_file():
|
||||
info = zipfile.ZipInfo(name)
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
info.flag_bits |= 0x800
|
||||
archive.writestr(info, _bundle_file_bytes(path))
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def _prune_expired_bundles(now: float | None = None) -> None:
|
||||
if not BUNDLE_DIR.is_dir():
|
||||
return
|
||||
cutoff = (now or time.time()) - TTL_SECONDS
|
||||
for path in BUNDLE_DIR.glob("*.zip"):
|
||||
try:
|
||||
if path.stat().st_mtime < cutoff:
|
||||
path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
|
||||
def register_rdp_bundle_zip(zip_bytes: bytes) -> str:
|
||||
token = secrets.token_urlsafe(32)
|
||||
BUNDLE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
_prune_expired_bundles()
|
||||
(BUNDLE_DIR / f"{token}.zip").write_bytes(zip_bytes)
|
||||
return token
|
||||
|
||||
|
||||
def get_rdp_bundle_zip(token: str) -> bytes | None:
|
||||
text = (token or "").strip()
|
||||
if not _TOKEN_RE.fullmatch(text):
|
||||
return None
|
||||
path = BUNDLE_DIR / f"{text}.zip"
|
||||
if not path.is_file():
|
||||
return None
|
||||
if time.time() - path.stat().st_mtime > TTL_SECONDS:
|
||||
path.unlink(missing_ok=True)
|
||||
return None
|
||||
return path.read_bytes()
|
||||
@@ -0,0 +1,324 @@
|
||||
"""Auto logoff stuck RDP sessions when RDG flap (or direct login failure) is detected."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from app.models import Event, Host
|
||||
from app.services.host_sessions import (
|
||||
list_windows_sessions,
|
||||
mark_event_session_terminated,
|
||||
parse_qwinsta_sessions,
|
||||
terminate_windows_session,
|
||||
)
|
||||
from app.services.rdg_client_host import ClientWorkstationNotFoundError, resolve_client_workstation
|
||||
from app.services.rdg_session_flap import (
|
||||
RDG_END_TYPES,
|
||||
RDG_SUCCESS_TYPE,
|
||||
_event_user,
|
||||
_stored_flap_pair_id,
|
||||
event_has_rdg_flap,
|
||||
find_rdg_success_before_end,
|
||||
)
|
||||
from app.services.rdg_winrm_actions import execute_logoff_via_winrm
|
||||
from app.services.rdg_workstation_session import (
|
||||
WORKSTATION_LOGIN_TYPE,
|
||||
_event_login_user,
|
||||
_login_already_closed,
|
||||
find_workstation_login_for_rdg_end,
|
||||
users_match_rdg,
|
||||
)
|
||||
from app.services.rdp_flap_settings import get_effective_rdp_flap_settings
|
||||
from app.services.win_admin_settings import get_effective_win_admin_for_host
|
||||
|
||||
logger = logging.getLogger("sac.rdp_flap_auto_disconnect")
|
||||
|
||||
AUTO_DISCONNECT_BY = "auto:rdp_flap"
|
||||
RDP_LOGIN_FAILED = "rdp.login.failed"
|
||||
AUTO_DISCONNECT_DETAILS_KEY = "rdp_flap_auto_disconnect"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AutoDisconnectResult:
|
||||
ok: bool
|
||||
message: str
|
||||
trigger_event_id: int
|
||||
workstation_host_id: int | None = None
|
||||
login_event_id: int | None = None
|
||||
session_ids: tuple[int, ...] = ()
|
||||
|
||||
|
||||
def _details_dict(event: Event) -> dict:
|
||||
raw = event.details
|
||||
return raw if isinstance(raw, dict) else {}
|
||||
|
||||
|
||||
def _already_auto_disconnected(event: Event) -> bool:
|
||||
details = _details_dict(event)
|
||||
block = details.get(AUTO_DISCONNECT_DETAILS_KEY)
|
||||
if not isinstance(block, dict):
|
||||
return False
|
||||
return block.get("ok") is True
|
||||
|
||||
|
||||
def _mark_auto_disconnect(event: Event, *, result: AutoDisconnectResult) -> None:
|
||||
details = dict(_details_dict(event))
|
||||
details[AUTO_DISCONNECT_DETAILS_KEY] = {
|
||||
"ok": result.ok,
|
||||
"message": result.message,
|
||||
"workstation_host_id": result.workstation_host_id,
|
||||
"login_event_id": result.login_event_id,
|
||||
"session_ids": list(result.session_ids),
|
||||
"at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
event.details = details
|
||||
flag_modified(event, "details")
|
||||
|
||||
|
||||
def _norm_user_filter(user: str) -> str:
|
||||
text = (user or "").strip()
|
||||
if "\\" in text:
|
||||
return text.split("\\")[-1].strip().lower()
|
||||
if "@" in text:
|
||||
return text.split("@")[0].strip().lower()
|
||||
return text.lower()
|
||||
|
||||
|
||||
def _sessions_for_user(sessions, user: str):
|
||||
needle = _norm_user_filter(user)
|
||||
if not needle:
|
||||
return []
|
||||
matched = []
|
||||
for row in sessions:
|
||||
if needle in _norm_user_filter(row.user):
|
||||
matched.append(row)
|
||||
return matched
|
||||
|
||||
|
||||
def find_open_workstation_login(db: Session, *, host_id: int, user: str) -> Event | None:
|
||||
if not user.strip():
|
||||
return None
|
||||
candidates = db.scalars(
|
||||
select(Event)
|
||||
.where(
|
||||
Event.host_id == host_id,
|
||||
Event.type == WORKSTATION_LOGIN_TYPE,
|
||||
)
|
||||
.order_by(Event.occurred_at.desc())
|
||||
).all()
|
||||
for login in candidates:
|
||||
if _login_already_closed(login):
|
||||
continue
|
||||
if users_match_rdg(_event_login_user(login), user):
|
||||
return login
|
||||
return None
|
||||
|
||||
|
||||
def _rdg_pair_success_event(db: Session, event: Event) -> Event | None:
|
||||
if event.type == RDG_SUCCESS_TYPE and event_has_rdg_flap(event):
|
||||
pair_id = _stored_flap_pair_id(event)
|
||||
if pair_id is not None:
|
||||
return db.get(Event, pair_id)
|
||||
return event
|
||||
if event.type in RDG_END_TYPES:
|
||||
pair_id = _stored_flap_pair_id(event)
|
||||
if pair_id is not None:
|
||||
return db.get(Event, pair_id)
|
||||
return find_rdg_success_before_end(db, event)
|
||||
return None
|
||||
|
||||
|
||||
def _disconnect_on_workstation(
|
||||
db: Session,
|
||||
*,
|
||||
trigger_event: Event,
|
||||
workstation: Host,
|
||||
rdg_event: Event | None,
|
||||
user: str,
|
||||
login_event: Event | None,
|
||||
) -> AutoDisconnectResult:
|
||||
win_cfg = get_effective_win_admin_for_host(db, workstation)
|
||||
sessions, qwinsta = list_windows_sessions(workstation, win_cfg)
|
||||
if qwinsta is None or not qwinsta.ok:
|
||||
message = qwinsta.message if qwinsta else "qwinsta failed"
|
||||
return AutoDisconnectResult(
|
||||
ok=False,
|
||||
message=message,
|
||||
trigger_event_id=trigger_event.id,
|
||||
workstation_host_id=workstation.id,
|
||||
login_event_id=login_event.id if login_event else None,
|
||||
)
|
||||
|
||||
parsed = parse_qwinsta_sessions(qwinsta.stdout, filter_user=user)
|
||||
matched = _sessions_for_user(parsed, user)
|
||||
if not matched and qwinsta.stdout.strip():
|
||||
parsed = parse_qwinsta_sessions(qwinsta.stdout)
|
||||
matched = _sessions_for_user(parsed, user)
|
||||
if not matched:
|
||||
snippet = " ".join(qwinsta.stdout.split())[:240]
|
||||
parsed_note = f", parsed={len(parsed)} session(s)" if parsed else ""
|
||||
message = (
|
||||
f"No matching Windows session for user {user} on {workstation.hostname}"
|
||||
f"{parsed_note}"
|
||||
)
|
||||
if snippet:
|
||||
message = f"{message}; qwinsta: {snippet}"
|
||||
return AutoDisconnectResult(
|
||||
ok=False,
|
||||
message=message,
|
||||
trigger_event_id=trigger_event.id,
|
||||
workstation_host_id=workstation.id,
|
||||
login_event_id=login_event.id if login_event else None,
|
||||
)
|
||||
|
||||
logged_off: list[int] = []
|
||||
errors: list[str] = []
|
||||
for row in matched:
|
||||
if rdg_event is not None:
|
||||
cmd = execute_logoff_via_winrm(
|
||||
db,
|
||||
rdg_event,
|
||||
session_id=int(row.session_id),
|
||||
requested_by=AUTO_DISCONNECT_BY,
|
||||
)
|
||||
if cmd.status == "completed":
|
||||
logged_off.append(int(row.session_id))
|
||||
else:
|
||||
errors.append(cmd.result_stderr or cmd.result_stdout or f"logoff {row.session_id} failed")
|
||||
else:
|
||||
result = terminate_windows_session(workstation, win_cfg, row.session_id)
|
||||
if result is not None and result.ok:
|
||||
logged_off.append(int(row.session_id))
|
||||
else:
|
||||
errors.append(result.message if result else f"logoff {row.session_id} failed")
|
||||
|
||||
if login_event is not None and logged_off:
|
||||
mark_event_session_terminated(login_event, by_username=AUTO_DISCONNECT_BY)
|
||||
|
||||
if logged_off:
|
||||
message = f"Auto logoff session(s) {', '.join(str(s) for s in logged_off)} on {workstation.hostname}"
|
||||
ok = True
|
||||
else:
|
||||
ok = False
|
||||
message = "; ".join(errors) if errors else "logoff failed"
|
||||
return AutoDisconnectResult(
|
||||
ok=ok,
|
||||
message=message,
|
||||
trigger_event_id=trigger_event.id,
|
||||
workstation_host_id=workstation.id,
|
||||
login_event_id=login_event.id if login_event else None,
|
||||
session_ids=tuple(logged_off),
|
||||
)
|
||||
|
||||
|
||||
def _auto_disconnect_rdg_flap(db: Session, event: Event) -> AutoDisconnectResult | None:
|
||||
if not event_has_rdg_flap(event):
|
||||
return None
|
||||
if _already_auto_disconnected(event):
|
||||
return None
|
||||
|
||||
rdg_success = _rdg_pair_success_event(db, event)
|
||||
if rdg_success is None:
|
||||
return None
|
||||
|
||||
user = _event_user(event) or _event_user(rdg_success)
|
||||
if not user:
|
||||
return None
|
||||
|
||||
try:
|
||||
workstation = resolve_client_workstation(db, rdg_success)
|
||||
except ClientWorkstationNotFoundError as exc:
|
||||
result = AutoDisconnectResult(
|
||||
ok=False,
|
||||
message=str(exc),
|
||||
trigger_event_id=event.id,
|
||||
)
|
||||
_mark_auto_disconnect(event, result=result)
|
||||
return result
|
||||
|
||||
rdg_end = event if event.type in RDG_END_TYPES else db.get(Event, _stored_flap_pair_id(event) or -1)
|
||||
login_event = find_workstation_login_for_rdg_end(db, rdg_end) if rdg_end is not None else None
|
||||
|
||||
result = _disconnect_on_workstation(
|
||||
db,
|
||||
trigger_event=event,
|
||||
workstation=workstation,
|
||||
rdg_event=rdg_success,
|
||||
user=user,
|
||||
login_event=login_event,
|
||||
)
|
||||
_mark_auto_disconnect(event, result=result)
|
||||
return result
|
||||
|
||||
|
||||
def _auto_disconnect_direct_rdp_failed(db: Session, event: Event) -> AutoDisconnectResult | None:
|
||||
if event.type != RDP_LOGIN_FAILED:
|
||||
return None
|
||||
if _already_auto_disconnected(event):
|
||||
return None
|
||||
host = event.host
|
||||
if host is None:
|
||||
return None
|
||||
|
||||
user = _event_login_user(event)
|
||||
if not user:
|
||||
return None
|
||||
|
||||
login_event = find_open_workstation_login(db, host_id=host.id, user=user)
|
||||
if login_event is None:
|
||||
return None
|
||||
|
||||
result = _disconnect_on_workstation(
|
||||
db,
|
||||
trigger_event=event,
|
||||
workstation=host,
|
||||
rdg_event=None,
|
||||
user=user,
|
||||
login_event=login_event,
|
||||
)
|
||||
_mark_auto_disconnect(event, result=result)
|
||||
return result
|
||||
|
||||
|
||||
def maybe_auto_disconnect_stuck_rdp_session(db: Session, event: Event) -> AutoDisconnectResult | None:
|
||||
"""Log off stuck user session when auto-disconnect is enabled."""
|
||||
if not get_effective_rdp_flap_settings(db).auto_disconnect:
|
||||
return None
|
||||
|
||||
result = _auto_disconnect_rdg_flap(db, event)
|
||||
if result is not None:
|
||||
if result.ok:
|
||||
logger.info(
|
||||
"auto rdp flap disconnect ok event_id=%s sessions=%s",
|
||||
event.event_id,
|
||||
result.session_ids,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"auto rdp flap disconnect failed event_id=%s: %s",
|
||||
event.event_id,
|
||||
result.message,
|
||||
)
|
||||
return result
|
||||
|
||||
result = _auto_disconnect_direct_rdp_failed(db, event)
|
||||
if result is not None:
|
||||
if result.ok:
|
||||
logger.info(
|
||||
"auto direct rdp disconnect ok event_id=%s sessions=%s",
|
||||
event.event_id,
|
||||
result.session_ids,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"auto direct rdp disconnect failed event_id=%s: %s",
|
||||
event.event_id,
|
||||
result.message,
|
||||
)
|
||||
return result
|
||||
@@ -0,0 +1,33 @@
|
||||
"""RDP / RDG flap auto-disconnect settings (ui_settings singleton)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RdpFlapSettings:
|
||||
auto_disconnect: bool
|
||||
source: str = "db"
|
||||
|
||||
|
||||
def get_effective_rdp_flap_settings(db: Session) -> RdpFlapSettings:
|
||||
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
|
||||
if row is None:
|
||||
return RdpFlapSettings(auto_disconnect=False, source="default")
|
||||
return RdpFlapSettings(auto_disconnect=bool(row.auto_rdp_flap_disconnect), source="db")
|
||||
|
||||
|
||||
def upsert_rdp_flap_settings(db: Session, *, auto_disconnect: bool) -> RdpFlapSettings:
|
||||
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
|
||||
if row is None:
|
||||
row = UiSettings(id=UI_SETTINGS_ROW_ID, show_sidebar_system_stats=True)
|
||||
db.add(row)
|
||||
row.auto_rdp_flap_disconnect = bool(auto_disconnect)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return get_effective_rdp_flap_settings(db)
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Correlate direct RDP logoff (Security 4634/4647) with workstation rdp.login.success."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from app.models import Event
|
||||
from app.services.host_sessions import (
|
||||
SESSION_TERMINATED_AT_KEY,
|
||||
_details_dict,
|
||||
_event_login_user,
|
||||
)
|
||||
from app.services.rdg_workstation_session import (
|
||||
WORKSTATION_LOGIN_TYPE,
|
||||
event_closed_by_rdg,
|
||||
users_match_rdg,
|
||||
)
|
||||
|
||||
SESSION_CLOSED_BY_LOGOFF_AT_KEY = "session_closed_by_logoff_at"
|
||||
SESSION_CLOSED_BY_LOGOFF_EVENT_ID_KEY = "session_closed_by_logoff_event_id"
|
||||
|
||||
LOGOFF_EVENT_TYPE = "rdp.session.logoff"
|
||||
LOGOFF_EVENT_TYPES = frozenset({LOGOFF_EVENT_TYPE})
|
||||
|
||||
|
||||
def event_closed_by_logoff(event: Event) -> bool:
|
||||
details = _details_dict(event)
|
||||
at = details.get(SESSION_CLOSED_BY_LOGOFF_AT_KEY)
|
||||
return at is not None and str(at).strip() != ""
|
||||
|
||||
|
||||
def mark_login_closed_by_logoff(login_event: Event, *, logoff_event: Event) -> None:
|
||||
details = dict(_details_dict(login_event))
|
||||
details[SESSION_CLOSED_BY_LOGOFF_AT_KEY] = logoff_event.occurred_at.isoformat()
|
||||
details[SESSION_CLOSED_BY_LOGOFF_EVENT_ID_KEY] = logoff_event.id
|
||||
login_event.details = details
|
||||
flag_modified(login_event, "details")
|
||||
|
||||
|
||||
def _login_already_closed(login_event: Event) -> bool:
|
||||
details = _details_dict(login_event)
|
||||
if details.get("session_terminated") is True:
|
||||
return True
|
||||
at = details.get(SESSION_TERMINATED_AT_KEY)
|
||||
if at is not None and str(at).strip() != "":
|
||||
return True
|
||||
if event_closed_by_rdg(login_event):
|
||||
return True
|
||||
return event_closed_by_logoff(login_event)
|
||||
|
||||
|
||||
def find_workstation_login_for_logoff(db: Session, logoff_event: Event) -> Event | None:
|
||||
if logoff_event.type not in LOGOFF_EVENT_TYPES:
|
||||
return None
|
||||
|
||||
logoff_user = _event_login_user(logoff_event)
|
||||
if not logoff_user:
|
||||
return None
|
||||
logoff_at = logoff_event.occurred_at
|
||||
host_id = logoff_event.host_id
|
||||
if host_id is None:
|
||||
return None
|
||||
|
||||
candidates = db.scalars(
|
||||
select(Event)
|
||||
.where(
|
||||
Event.host_id == host_id,
|
||||
Event.type == WORKSTATION_LOGIN_TYPE,
|
||||
Event.occurred_at <= logoff_at,
|
||||
Event.id != logoff_event.id,
|
||||
)
|
||||
.order_by(Event.occurred_at.desc())
|
||||
).all()
|
||||
|
||||
logoff_details = _details_dict(logoff_event)
|
||||
logoff_ip = str(logoff_details.get("ip_address") or "").strip()
|
||||
|
||||
for login in candidates:
|
||||
if _login_already_closed(login):
|
||||
continue
|
||||
login_user = _event_login_user(login)
|
||||
if not users_match_rdg(login_user, logoff_user):
|
||||
continue
|
||||
if logoff_ip and logoff_ip not in ("", "-"):
|
||||
login_ip = str(_details_dict(login).get("ip_address") or "").strip()
|
||||
if login_ip and login_ip not in ("", "-") and login_ip != logoff_ip:
|
||||
continue
|
||||
return login
|
||||
return None
|
||||
|
||||
|
||||
def find_logoff_after_workstation_login(db: Session, login_event: Event) -> Event | None:
|
||||
"""Runtime lookup for historical logins without persisted close flag."""
|
||||
if login_event.type != WORKSTATION_LOGIN_TYPE:
|
||||
return None
|
||||
if _login_already_closed(login_event):
|
||||
return None
|
||||
|
||||
host_id = login_event.host_id
|
||||
if host_id is None:
|
||||
return None
|
||||
|
||||
login_user = _event_login_user(login_event)
|
||||
if not login_user:
|
||||
return None
|
||||
login_at = login_event.occurred_at
|
||||
login_ip = str(_details_dict(login_event).get("ip_address") or "").strip()
|
||||
|
||||
candidates = db.scalars(
|
||||
select(Event)
|
||||
.where(
|
||||
Event.host_id == host_id,
|
||||
Event.type == LOGOFF_EVENT_TYPE,
|
||||
Event.occurred_at >= login_at,
|
||||
Event.id != login_event.id,
|
||||
)
|
||||
.order_by(Event.occurred_at.asc())
|
||||
).all()
|
||||
|
||||
for logoff in candidates:
|
||||
if not users_match_rdg(_event_login_user(logoff), login_user):
|
||||
continue
|
||||
logoff_ip = str(_details_dict(logoff).get("ip_address") or "").strip()
|
||||
if login_ip and login_ip not in ("", "-") and logoff_ip and logoff_ip not in ("", "-"):
|
||||
if login_ip != logoff_ip:
|
||||
continue
|
||||
return logoff
|
||||
return None
|
||||
|
||||
|
||||
def resolve_workstation_login_closed_by_logoff(db: Session, login_event: Event) -> bool:
|
||||
if event_closed_by_logoff(login_event):
|
||||
return True
|
||||
return find_logoff_after_workstation_login(db, login_event) is not None
|
||||
|
||||
|
||||
def close_workstation_session_for_rdp_logoff(db: Session, logoff_event: Event) -> Event | None:
|
||||
"""On direct RDP logoff, mark matching open workstation login as session-closed."""
|
||||
if logoff_event.type not in LOGOFF_EVENT_TYPES:
|
||||
return None
|
||||
|
||||
login = find_workstation_login_for_logoff(db, logoff_event)
|
||||
if login is None:
|
||||
return None
|
||||
mark_login_closed_by_logoff(login, logoff_event=logoff_event)
|
||||
return login
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Format and extract RDP/RDG session_duration_sec for UI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def extract_session_duration_sec(details: dict[str, Any] | None) -> int | None:
|
||||
if not isinstance(details, dict):
|
||||
return None
|
||||
raw = details.get("session_duration_sec")
|
||||
if raw is None or raw == "":
|
||||
return None
|
||||
try:
|
||||
value = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if value < 0:
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def format_session_duration(seconds: int) -> str:
|
||||
"""
|
||||
Human-readable session length for lists:
|
||||
- under 24h → HH:MM:SS (05:05:24)
|
||||
- 24h+ → Nд HH:MM:SS (1д 00:01:25)
|
||||
"""
|
||||
if seconds < 0:
|
||||
return "—"
|
||||
days, rem = divmod(int(seconds), 86_400)
|
||||
hours, rem = divmod(rem, 3600)
|
||||
minutes, secs = divmod(rem, 60)
|
||||
clock = f"{hours:02d}:{minutes:02d}:{secs:02d}"
|
||||
if days:
|
||||
return f"{days}д {clock}"
|
||||
return clock
|
||||
@@ -0,0 +1,588 @@
|
||||
"""SSH connectivity and remote commands for Linux hosts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import socket
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import Host
|
||||
|
||||
SSH_MONITOR_UPDATE_STATE_FILE = "/var/lib/ssh-monitor/agent-update-in-progress"
|
||||
SSH_MONITOR_UPDATE_LOG_PATH = "/var/log/update_script.log"
|
||||
SSH_MONITOR_UPDATE_SCRIPT = "/opt/scripts/update_ssh_monitor.sh"
|
||||
SSH_MONITOR_UPDATE_DIR = "/opt/scripts/update"
|
||||
SSH_MONITOR_REPO_NAME = "ssh-monitor"
|
||||
SSH_MONITOR_UPDATE_REPO_URL = "https://git.kalinamall.ru/PapaTramp/ssh-monitor.git"
|
||||
SSH_MONITOR_BINARY = "/usr/local/bin/ssh-monitor"
|
||||
SSH_MONITOR_VERSION_CMD = (
|
||||
f"grep -m1 '^SSH_MONITOR_VERSION=' {SSH_MONITOR_BINARY} 2>/dev/null "
|
||||
"| sed -E 's/^SSH_MONITOR_VERSION=[\"'\\'']*([^\"'\\'']+)[\"'\\'']*$/\\1/'"
|
||||
)
|
||||
SSH_OUTPUT_MAX_LEN = 12_000
|
||||
_AGENT_VERSION_RE = re.compile(r"(\d+\.\d+\.\d+(?:-SAC)?)", re.IGNORECASE)
|
||||
_IPV4_RE = re.compile(r"^\d{1,3}(?:\.\d{1,3}){3}$")
|
||||
_SSH_TRANSIENT_ERROR_MARKERS = (
|
||||
"no existing session",
|
||||
"error reading ssh protocol banner",
|
||||
"connection reset",
|
||||
"connection lost",
|
||||
"session not active",
|
||||
"eof",
|
||||
"socket is closed",
|
||||
"connection timed out",
|
||||
)
|
||||
_SSH_CONNECT_ATTEMPTS = 2
|
||||
|
||||
|
||||
class LinuxAdminNotConfiguredError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class HostNotLinuxError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class HostTargetMissingError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SshCommandResult:
|
||||
ok: bool
|
||||
message: str
|
||||
target: str
|
||||
stdout: str = ""
|
||||
stderr: str = ""
|
||||
exit_code: int | None = None
|
||||
agent_version: str | None = None
|
||||
|
||||
|
||||
def is_linux_host(host: Host) -> bool:
|
||||
if (host.os_family or "").strip().lower() == "linux":
|
||||
return True
|
||||
return (host.product or "").strip() == "ssh-monitor"
|
||||
|
||||
|
||||
def _is_ipv4(value: str) -> bool:
|
||||
return bool(_IPV4_RE.match(value.strip()))
|
||||
|
||||
|
||||
def _looks_like_ssh_hostname(value: str) -> bool:
|
||||
"""Короткое имя или FQDN; не человекочитаемый display_name вроде «Ubabuba Kalina (10.10.36.9)»."""
|
||||
text = value.strip()
|
||||
if not text or " " in text or "(" in text or ")" in text:
|
||||
return False
|
||||
if _is_ipv4(text):
|
||||
return True
|
||||
if len(text) > 253:
|
||||
return False
|
||||
return all(ch.isalnum() or ch in ".-_" for ch in text)
|
||||
|
||||
|
||||
def _ssh_name_resolves(name: str) -> bool:
|
||||
if _is_ipv4(name):
|
||||
return True
|
||||
try:
|
||||
socket.getaddrinfo(name.strip(), 22, type=socket.SOCK_STREAM)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def iter_ssh_targets(host: Host) -> list[str]:
|
||||
if not is_linux_host(host):
|
||||
raise HostNotLinuxError("Host is not Linux")
|
||||
|
||||
seen: set[str] = set()
|
||||
ordered: list[str] = []
|
||||
|
||||
def add(value: str | None) -> None:
|
||||
text = (value or "").strip()
|
||||
if not text or text.casefold() in seen:
|
||||
return
|
||||
if not _looks_like_ssh_hostname(text):
|
||||
return
|
||||
if not _ssh_name_resolves(text):
|
||||
return
|
||||
seen.add(text.casefold())
|
||||
ordered.append(text)
|
||||
|
||||
add(host.hostname)
|
||||
|
||||
inventory = host.inventory if isinstance(host.inventory, dict) else {}
|
||||
computer_name = inventory.get("computer_name")
|
||||
if isinstance(computer_name, str):
|
||||
add(computer_name)
|
||||
|
||||
if host.display_name and _looks_like_ssh_hostname(host.display_name):
|
||||
add(host.display_name)
|
||||
|
||||
add(host.ipv4)
|
||||
if not ordered:
|
||||
raise HostTargetMissingError("Host has no resolvable hostname or IPv4 for SSH")
|
||||
return ordered
|
||||
|
||||
|
||||
def _truncate_output(text: str) -> str:
|
||||
if len(text) <= SSH_OUTPUT_MAX_LEN:
|
||||
return text
|
||||
return text[:SSH_OUTPUT_MAX_LEN] + "\n… (truncated)"
|
||||
|
||||
|
||||
def _is_transient_ssh_error(exc: BaseException) -> bool:
|
||||
text = str(exc).casefold()
|
||||
return any(marker in text for marker in _SSH_TRANSIENT_ERROR_MARKERS)
|
||||
|
||||
|
||||
def _ssh_connect_hint(exc: BaseException) -> str:
|
||||
text = str(exc).casefold()
|
||||
if "no existing session" in text:
|
||||
return (
|
||||
" Сервер закрыл SSH-сессию во время подключения или auth "
|
||||
"(проверьте пароль admin, лимиты sshd MaxSessions/MaxStartups, "
|
||||
"доступ SAC→хост по 22/tcp; на SAC не должен мешать ssh-agent)."
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
def _connect_ssh_client(
|
||||
client,
|
||||
*,
|
||||
target: str,
|
||||
user: str,
|
||||
password: str,
|
||||
connect_timeout_sec: int,
|
||||
) -> None:
|
||||
client.connect(
|
||||
hostname=target,
|
||||
username=user,
|
||||
password=password,
|
||||
timeout=connect_timeout_sec,
|
||||
banner_timeout=connect_timeout_sec,
|
||||
auth_timeout=connect_timeout_sec,
|
||||
allow_agent=False,
|
||||
look_for_keys=False,
|
||||
compress=False,
|
||||
)
|
||||
|
||||
|
||||
def _close_ssh_client(client) -> None:
|
||||
if client is None:
|
||||
return
|
||||
try:
|
||||
transport = client.get_transport()
|
||||
if transport is not None and transport.is_active():
|
||||
transport.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
client.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _shell_single_quote(value: str) -> str:
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
|
||||
def _remote_shell_command(
|
||||
user: str,
|
||||
remote_cmd: str,
|
||||
*,
|
||||
need_root: bool = False,
|
||||
login_shell: bool = True,
|
||||
) -> tuple[str, bool]:
|
||||
"""Build remote shell command. Returns (command, needs_sudo_password_on_stdin)."""
|
||||
safe_cmd = _shell_single_quote(remote_cmd)
|
||||
bash_flag = "lc" if login_shell else "c"
|
||||
if user == "root" or not need_root:
|
||||
return f"bash -{bash_flag} {safe_cmd}", False
|
||||
return f"sudo -S -p '' bash -{bash_flag} {safe_cmd}", True
|
||||
|
||||
|
||||
def _configure_ssh_client(client, target: str) -> None:
|
||||
import paramiko
|
||||
|
||||
settings = get_settings()
|
||||
if settings.sac_ssh_auto_add_host_key:
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
return
|
||||
|
||||
client.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
client.load_system_host_keys()
|
||||
known_hosts = (settings.sac_ssh_known_hosts_file or "").strip()
|
||||
if known_hosts:
|
||||
try:
|
||||
client.load_host_keys(known_hosts)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _exec_remote_command(
|
||||
client,
|
||||
*,
|
||||
shell_cmd: str,
|
||||
password: str,
|
||||
needs_sudo_password: bool,
|
||||
command_timeout_sec: int,
|
||||
) -> tuple[int, str, str]:
|
||||
if needs_sudo_password:
|
||||
stdin, stdout, stderr = client.exec_command(shell_cmd, timeout=command_timeout_sec, get_pty=True)
|
||||
stdin.write(f"{password}\n")
|
||||
stdin.flush()
|
||||
stdin.channel.shutdown_write()
|
||||
else:
|
||||
_stdin, stdout, stderr = client.exec_command(shell_cmd, timeout=command_timeout_sec)
|
||||
exit_code = stdout.channel.recv_exit_status()
|
||||
out_text = _truncate_output(stdout.read().decode("utf-8", errors="replace"))
|
||||
err_text = _truncate_output(stderr.read().decode("utf-8", errors="replace"))
|
||||
return exit_code, out_text, err_text
|
||||
|
||||
|
||||
def run_ssh_command(
|
||||
*,
|
||||
target: str,
|
||||
user: str,
|
||||
password: str,
|
||||
remote_cmd: str,
|
||||
connect_timeout_sec: int = 15,
|
||||
command_timeout_sec: int = 600,
|
||||
need_root: bool = False,
|
||||
login_shell: bool = True,
|
||||
) -> SshCommandResult:
|
||||
target = target.strip()
|
||||
user = user.strip()
|
||||
if not target or not user or not password:
|
||||
raise LinuxAdminNotConfiguredError("Linux SSH admin credentials or target missing")
|
||||
|
||||
try:
|
||||
import paramiko
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("paramiko is not installed on SAC server") from exc
|
||||
|
||||
shell_cmd, needs_sudo_password = _remote_shell_command(
|
||||
user,
|
||||
remote_cmd,
|
||||
need_root=need_root,
|
||||
login_shell=login_shell,
|
||||
)
|
||||
exit_code: int | None = None
|
||||
out_text = ""
|
||||
err_text = ""
|
||||
|
||||
for attempt in range(1, _SSH_CONNECT_ATTEMPTS + 1):
|
||||
client = paramiko.SSHClient()
|
||||
try:
|
||||
_configure_ssh_client(client, target)
|
||||
_connect_ssh_client(
|
||||
client,
|
||||
target=target,
|
||||
user=user,
|
||||
password=password,
|
||||
connect_timeout_sec=connect_timeout_sec,
|
||||
)
|
||||
exit_code, out_text, err_text = _exec_remote_command(
|
||||
client,
|
||||
shell_cmd=shell_cmd,
|
||||
password=password,
|
||||
needs_sudo_password=needs_sudo_password,
|
||||
command_timeout_sec=command_timeout_sec,
|
||||
)
|
||||
break
|
||||
except paramiko.AuthenticationException:
|
||||
return SshCommandResult(
|
||||
ok=False,
|
||||
message=f"SSH auth failed for {user}@{target}",
|
||||
target=target,
|
||||
)
|
||||
except (socket.timeout, TimeoutError):
|
||||
return SshCommandResult(
|
||||
ok=False,
|
||||
message=f"SSH timeout ({connect_timeout_sec}s connect / {command_timeout_sec}s cmd) to {target}",
|
||||
target=target,
|
||||
)
|
||||
except Exception as exc:
|
||||
if attempt < _SSH_CONNECT_ATTEMPTS and _is_transient_ssh_error(exc):
|
||||
continue
|
||||
hint = _ssh_connect_hint(exc)
|
||||
return SshCommandResult(
|
||||
ok=False,
|
||||
message=f"SSH error ({target}): {exc}{hint}",
|
||||
target=target,
|
||||
)
|
||||
finally:
|
||||
_close_ssh_client(client)
|
||||
else:
|
||||
return SshCommandResult(
|
||||
ok=False,
|
||||
message=f"SSH error ({target}): repeated transient connection failures",
|
||||
target=target,
|
||||
)
|
||||
|
||||
ok = exit_code == 0
|
||||
if ok:
|
||||
message = f"SSH OK ({target}), exit 0"
|
||||
if out_text.strip():
|
||||
message = f"{message}\n{out_text.strip().splitlines()[0][:200]}"
|
||||
else:
|
||||
detail = err_text.strip() or out_text.strip() or f"exit code {exit_code}"
|
||||
message = f"SSH command failed ({target}): {detail[:500]}"
|
||||
|
||||
return SshCommandResult(
|
||||
ok=ok,
|
||||
message=message,
|
||||
target=target,
|
||||
stdout=out_text,
|
||||
stderr=err_text,
|
||||
exit_code=exit_code,
|
||||
)
|
||||
|
||||
|
||||
def _ssh_output_hostname(stdout: str) -> str:
|
||||
"""Last line that looks like a hostname (MOTD/login banners may precede command output)."""
|
||||
candidates = [ln.strip() for ln in stdout.splitlines() if ln.strip()]
|
||||
for line in reversed(candidates):
|
||||
if re.match(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,253}$", line):
|
||||
return line
|
||||
return candidates[-1] if candidates else ""
|
||||
|
||||
|
||||
def test_ssh_connection(
|
||||
*,
|
||||
target: str,
|
||||
user: str,
|
||||
password: str,
|
||||
timeout_sec: int = 15,
|
||||
) -> SshCommandResult:
|
||||
result = run_ssh_command(
|
||||
target=target,
|
||||
user=user,
|
||||
password=password,
|
||||
remote_cmd="hostname",
|
||||
connect_timeout_sec=timeout_sec,
|
||||
command_timeout_sec=timeout_sec,
|
||||
login_shell=False,
|
||||
)
|
||||
if result.ok and result.stdout.strip():
|
||||
host = _ssh_output_hostname(result.stdout)
|
||||
if not host:
|
||||
return result
|
||||
return SshCommandResult(
|
||||
ok=True,
|
||||
message=f"SSH OK, hostname={host}",
|
||||
target=target,
|
||||
stdout=result.stdout,
|
||||
stderr=result.stderr,
|
||||
exit_code=result.exit_code,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def parse_ssh_monitor_version_text(text: str) -> str | None:
|
||||
if not text:
|
||||
return None
|
||||
match = _AGENT_VERSION_RE.search(text)
|
||||
if not match:
|
||||
return None
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def probe_ssh_monitor_version(
|
||||
*,
|
||||
target: str,
|
||||
user: str,
|
||||
password: str,
|
||||
) -> str | None:
|
||||
result = run_ssh_command(
|
||||
target=target,
|
||||
user=user,
|
||||
password=password,
|
||||
remote_cmd=SSH_MONITOR_VERSION_CMD,
|
||||
command_timeout_sec=30,
|
||||
login_shell=False,
|
||||
)
|
||||
if result.ok and result.stdout.strip():
|
||||
version = parse_ssh_monitor_version_text(result.stdout)
|
||||
if version:
|
||||
return version
|
||||
if result.stdout or result.stderr:
|
||||
return parse_ssh_monitor_version_text(f"{result.stdout}\n{result.stderr}")
|
||||
return None
|
||||
|
||||
|
||||
def _ssh_monitor_preflight_git_remote(repo_url: str) -> str:
|
||||
"""Align clone remotes with SAC-trusted URL (works even before updater script is refreshed)."""
|
||||
safe_repo = _shell_single_quote(repo_url.strip())
|
||||
repo_dir = _shell_single_quote(f"{SSH_MONITOR_UPDATE_DIR}/{SSH_MONITOR_REPO_NAME}")
|
||||
return (
|
||||
f"if [ -d {repo_dir}/.git ]; then "
|
||||
f"git -C {repo_dir} remote add kalinamall {safe_repo} 2>/dev/null || "
|
||||
f"git -C {repo_dir} remote set-url kalinamall {safe_repo}; "
|
||||
f"if git -C {repo_dir} remote get-url origin >/dev/null 2>&1; then "
|
||||
f"git -C {repo_dir} remote set-url origin {safe_repo}; "
|
||||
f"fi; fi"
|
||||
)
|
||||
|
||||
|
||||
def _ssh_monitor_update_invoke_command(
|
||||
repo_url: str,
|
||||
*,
|
||||
git_branch: str = "main",
|
||||
) -> str:
|
||||
"""Run installed updater with SAC-trusted git URL (overrides script default / stale origin)."""
|
||||
safe_repo = _shell_single_quote(repo_url.strip())
|
||||
safe_branch = _shell_single_quote((git_branch or "main").strip() or "main")
|
||||
preflight = _ssh_monitor_preflight_git_remote(repo_url)
|
||||
return f"{preflight}; UPDATE_VIA_SAC=1 REPO_URL={safe_repo} GIT_BRANCH={safe_branch} {SSH_MONITOR_UPDATE_SCRIPT}"
|
||||
|
||||
|
||||
def _ssh_monitor_bootstrap_command(repo_url: str, *, git_branch: str = "main") -> str:
|
||||
"""Clone ssh-monitor repo and install updater when /opt/scripts/update_ssh_monitor.sh is absent."""
|
||||
safe_repo = repo_url.replace("\\", "\\\\").replace('"', '\\"')
|
||||
safe_branch = (git_branch or "main").replace("\\", "\\\\").replace('"', '\\"')
|
||||
return (
|
||||
f'UPDATE_DIR="{SSH_MONITOR_UPDATE_DIR}";'
|
||||
f'REPO_URL="{safe_repo}";'
|
||||
f'GIT_BRANCH="{safe_branch}";'
|
||||
f'SCRIPT_NAME="{SSH_MONITOR_REPO_NAME}";'
|
||||
f'INSTALL="{SSH_MONITOR_UPDATE_SCRIPT}";'
|
||||
f'mkdir -p "$UPDATE_DIR" && cd "$UPDATE_DIR" && '
|
||||
f'([ -d "$SCRIPT_NAME" ] || git clone -b "$GIT_BRANCH" "$REPO_URL" "$SCRIPT_NAME") && '
|
||||
f'cp "$UPDATE_DIR/$SCRIPT_NAME/update_ssh_monitor.sh" "$INSTALL" && '
|
||||
f'chmod 750 "$INSTALL" && UPDATE_VIA_SAC=1 REPO_URL="$REPO_URL" GIT_BRANCH="$GIT_BRANCH" "$INSTALL" --deploy'
|
||||
)
|
||||
|
||||
|
||||
def _ssh_monitor_mark_update_begin_prefix() -> str:
|
||||
"""State-file до updater: lifecycle/sudo на агенте глушатся раньше bootstrap."""
|
||||
return (
|
||||
f"mkdir -p /var/lib/ssh-monitor && "
|
||||
f"date +%s > {SSH_MONITOR_UPDATE_STATE_FILE} && "
|
||||
f"chmod 600 {SSH_MONITOR_UPDATE_STATE_FILE} 2>/dev/null; "
|
||||
)
|
||||
|
||||
|
||||
def run_ssh_monitor_update(
|
||||
*,
|
||||
target: str,
|
||||
user: str,
|
||||
password: str,
|
||||
repo_url: str | None = None,
|
||||
git_branch: str | None = None,
|
||||
) -> SshCommandResult:
|
||||
script = SSH_MONITOR_UPDATE_SCRIPT
|
||||
effective_repo = (repo_url or SSH_MONITOR_UPDATE_REPO_URL).strip()
|
||||
effective_branch = (git_branch or "main").strip() or "main"
|
||||
update_cmd = _ssh_monitor_update_invoke_command(
|
||||
effective_repo,
|
||||
git_branch=effective_branch,
|
||||
)
|
||||
check_cmd = f"test -x {script} && echo ready || echo missing"
|
||||
probe = run_ssh_command(
|
||||
target=target,
|
||||
user=user,
|
||||
password=password,
|
||||
remote_cmd=check_cmd,
|
||||
command_timeout_sec=30,
|
||||
login_shell=False,
|
||||
)
|
||||
if not probe.ok:
|
||||
return SshCommandResult(
|
||||
ok=False,
|
||||
message=f"SSH probe failed on {target}: {probe.message}",
|
||||
target=target,
|
||||
stdout=probe.stdout,
|
||||
stderr=probe.stderr,
|
||||
exit_code=probe.exit_code,
|
||||
)
|
||||
|
||||
bootstrapped = False
|
||||
if "missing" in probe.stdout:
|
||||
bootstrap_cmd = _ssh_monitor_bootstrap_command(
|
||||
effective_repo,
|
||||
git_branch=effective_branch,
|
||||
)
|
||||
updated = run_ssh_command(
|
||||
target=target,
|
||||
user=user,
|
||||
password=password,
|
||||
remote_cmd=_ssh_monitor_mark_update_begin_prefix() + bootstrap_cmd,
|
||||
command_timeout_sec=900,
|
||||
need_root=True,
|
||||
login_shell=False,
|
||||
)
|
||||
bootstrapped = True
|
||||
else:
|
||||
updated = run_ssh_command(
|
||||
target=target,
|
||||
user=user,
|
||||
password=password,
|
||||
remote_cmd=_ssh_monitor_mark_update_begin_prefix() + update_cmd,
|
||||
command_timeout_sec=900,
|
||||
need_root=True,
|
||||
login_shell=False,
|
||||
)
|
||||
if not updated.ok:
|
||||
prefix = "Updater bootstrap failed" if bootstrapped else "SSH update failed"
|
||||
return SshCommandResult(
|
||||
ok=False,
|
||||
message=f"{prefix} ({target}): {updated.message}",
|
||||
target=updated.target,
|
||||
stdout=updated.stdout,
|
||||
stderr=updated.stderr,
|
||||
exit_code=updated.exit_code,
|
||||
)
|
||||
|
||||
version = probe_ssh_monitor_version(target=target, user=user, password=password)
|
||||
if not version:
|
||||
version = parse_ssh_monitor_version_text(updated.stdout)
|
||||
if version:
|
||||
prefix = "Bootstrap + update OK" if bootstrapped else updated.message
|
||||
message = f"{prefix}\nagent version: {version}"
|
||||
return SshCommandResult(
|
||||
ok=True,
|
||||
message=message,
|
||||
target=updated.target,
|
||||
stdout=updated.stdout,
|
||||
stderr=updated.stderr,
|
||||
exit_code=updated.exit_code,
|
||||
agent_version=version,
|
||||
)
|
||||
if bootstrapped:
|
||||
return SshCommandResult(
|
||||
ok=True,
|
||||
message=f"Bootstrap + update OK ({target}), exit 0",
|
||||
target=updated.target,
|
||||
stdout=updated.stdout,
|
||||
stderr=updated.stderr,
|
||||
exit_code=updated.exit_code,
|
||||
)
|
||||
return updated
|
||||
|
||||
|
||||
def tail_ssh_monitor_update_log(
|
||||
*,
|
||||
target: str,
|
||||
user: str,
|
||||
password: str,
|
||||
lines: int = 120,
|
||||
) -> str:
|
||||
"""Хвост /var/log/update_script.log на удалённом хосте (для live-лога в SAC UI)."""
|
||||
safe_lines = max(20, min(int(lines), 400))
|
||||
remote_cmd = (
|
||||
f"test -r {SSH_MONITOR_UPDATE_LOG_PATH} && "
|
||||
f"tail -n {safe_lines} {SSH_MONITOR_UPDATE_LOG_PATH} 2>/dev/null || true"
|
||||
)
|
||||
result = run_ssh_command(
|
||||
target=target,
|
||||
user=user,
|
||||
password=password,
|
||||
remote_cmd=remote_cmd,
|
||||
command_timeout_sec=25,
|
||||
need_root=True,
|
||||
login_shell=False,
|
||||
)
|
||||
text = (result.stdout or "").strip()
|
||||
return _truncate_output(text)
|
||||
@@ -506,25 +506,69 @@ def format_event_telegram_html(event: Event) -> str:
|
||||
return _append_event_source(_format_event_body_html(event), event)
|
||||
|
||||
|
||||
def _format_rdg_html(event: Event) -> str:
|
||||
details = _details_dict(event)
|
||||
if event.type.endswith(".success"):
|
||||
header = "✅ RD Gateway: подключение"
|
||||
elif event.type.endswith(".disconnected"):
|
||||
header = "ℹ️ RD Gateway: отключение"
|
||||
_TELEGRAM_INLINE_TAGS = re.compile(r"</?(?:b|i|u|s|code|pre|tg-spoiler)(\s[^>]*)?>", re.I)
|
||||
_TELEGRAM_ANCHOR_OPEN = re.compile(r"<a\b[^>]*>", re.I)
|
||||
_TELEGRAM_ANCHOR_CLOSE = re.compile(r"</a>", re.I)
|
||||
|
||||
_MOBILE_PUSH_BODY_MAX = 3500
|
||||
|
||||
|
||||
def telegram_html_to_plaintext(text: str) -> str:
|
||||
"""Plain text for Seaca push from the same HTML templates as Telegram."""
|
||||
out = sanitize_telegram_html(text)
|
||||
out = _TELEGRAM_INLINE_TAGS.sub("", out)
|
||||
out = _TELEGRAM_ANCHOR_OPEN.sub("", out)
|
||||
out = _TELEGRAM_ANCHOR_CLOSE.sub("", out)
|
||||
out = html.unescape(out)
|
||||
out = re.sub(r"\n{3,}", "\n\n", out)
|
||||
return out.strip()
|
||||
|
||||
|
||||
def format_event_mobile_push(event: Event) -> tuple[str, str]:
|
||||
"""Title + body for FCM data payload (mirrors Telegram wording)."""
|
||||
plain = telegram_html_to_plaintext(format_event_telegram_html(event))
|
||||
lines = [line.strip() for line in plain.split("\n") if line.strip()]
|
||||
if lines:
|
||||
title = lines[0]
|
||||
body = "\n".join(lines[1:]).strip()
|
||||
if not body:
|
||||
body = (event.summary or "").strip()
|
||||
else:
|
||||
header = "❌ RD Gateway: ошибка"
|
||||
title = f"[{event.severity}] {event.title}"
|
||||
body = (event.summary or "").strip()
|
||||
if len(title) > 120:
|
||||
title = title[:117] + "…"
|
||||
if len(body) > _MOBILE_PUSH_BODY_MAX:
|
||||
body = body[: _MOBILE_PUSH_BODY_MAX - 1] + "…"
|
||||
return title, body
|
||||
|
||||
|
||||
def _format_rdg_html(event: Event) -> str:
|
||||
from app.services.rdg_display import build_rdg_display
|
||||
|
||||
details = _details_dict(event)
|
||||
display = build_rdg_display(event)
|
||||
if event.type.endswith(".success"):
|
||||
header = "✅ RDS через RD Gateway"
|
||||
elif event.type.endswith(".disconnected"):
|
||||
header = "ℹ️ RDS отключение (RD Gateway)"
|
||||
else:
|
||||
header = "❌ RDS ошибка (RD Gateway)"
|
||||
msg = f"<b>{header}</b>\n"
|
||||
if display is not None and display.access_path:
|
||||
msg += _line("🔀", "Путь", html_escape(display.access_path))
|
||||
msg += _line("👤", "Пользователь", html_escape(_detail(details, "user", "username")))
|
||||
msg += _line("🏢", "Хост", host_label(event.host))
|
||||
msg += _line("🚪", "Шлюз RDG", host_label(event.host))
|
||||
msg += _line("🌐", "Внешний IP", html_escape(_detail(details, "external_ip", "ip_address")))
|
||||
msg += _line("🏠", "Внутренний IP", html_escape(_detail(details, "internal_ip", default="-")))
|
||||
msg += _line("🖥️", "Рабочий ПК", html_escape(_detail(details, "internal_ip", default="-")))
|
||||
err = _detail(details, "gateway_error_code", "error_code", default="")
|
||||
if err != "-":
|
||||
msg += _line("⚠️", "Код ошибки", html_escape(err))
|
||||
dur = _detail(details, "session_duration_sec", default="")
|
||||
if dur not in ("-", "0", ""):
|
||||
msg += _line("⏱️", "Длительность", f"{html_escape(dur)} с")
|
||||
from app.services.session_duration import extract_session_duration_sec, format_session_duration
|
||||
|
||||
dur_sec = extract_session_duration_sec(details if isinstance(details, dict) else None)
|
||||
if dur_sec is not None and dur_sec > 0:
|
||||
msg += _line("⏱️", "Длительность", html_escape(format_session_duration(dur_sec)))
|
||||
msg += _line("🕐", "Время", format_time(event.occurred_at))
|
||||
win_id = _detail(details, "event_id_windows", default="")
|
||||
if win_id != "-":
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Effective Windows domain admin credentials (host → DB → env)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models.host import Host
|
||||
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WinAdminConfig:
|
||||
user: str
|
||||
password: str
|
||||
source: str # host | env | db
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return bool(self.user.strip() and self.password.strip())
|
||||
|
||||
|
||||
def normalize_win_admin_user(user: str) -> str:
|
||||
"""DOMAIN\\user — один обратный слэш; убрать лишнее экранирование из env/UI."""
|
||||
text = user.strip()
|
||||
if not text:
|
||||
return ""
|
||||
text = text.replace("\\\\", "\\")
|
||||
if "\\" not in text and "@" not in text and "/" not in text:
|
||||
# NETBIOS\user без слэша — не трогаем (оператор допишет в UI)
|
||||
return text
|
||||
return text
|
||||
|
||||
|
||||
def _win_admin_from_env() -> WinAdminConfig:
|
||||
settings = get_settings()
|
||||
return WinAdminConfig(
|
||||
user=normalize_win_admin_user(settings.sac_win_admin_user),
|
||||
password=settings.sac_win_admin_password,
|
||||
source="env",
|
||||
)
|
||||
|
||||
|
||||
def get_effective_win_admin_config(db: Session | None = None) -> WinAdminConfig:
|
||||
if db is None:
|
||||
from app.database import SessionLocal
|
||||
|
||||
session = SessionLocal()
|
||||
try:
|
||||
return get_effective_win_admin_config(session)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
|
||||
env_cfg = _win_admin_from_env()
|
||||
if row is None:
|
||||
return env_cfg
|
||||
|
||||
user = normalize_win_admin_user((row.win_admin_user or "").strip() or env_cfg.user)
|
||||
password = (row.win_admin_password or "") or env_cfg.password
|
||||
has_db_values = bool((row.win_admin_user or "").strip() or (row.win_admin_password or "").strip())
|
||||
return WinAdminConfig(
|
||||
user=user,
|
||||
password=password,
|
||||
source="db" if has_db_values else env_cfg.source,
|
||||
)
|
||||
|
||||
|
||||
def upsert_win_admin_settings(
|
||||
db: Session,
|
||||
*,
|
||||
user: str | None = None,
|
||||
password: str | None = None,
|
||||
) -> WinAdminConfig:
|
||||
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
|
||||
env_cfg = _win_admin_from_env()
|
||||
if row is None:
|
||||
row = UiSettings(
|
||||
id=UI_SETTINGS_ROW_ID,
|
||||
show_sidebar_system_stats=True,
|
||||
win_admin_user=env_cfg.user or None,
|
||||
win_admin_password=env_cfg.password or None,
|
||||
)
|
||||
db.add(row)
|
||||
|
||||
if user is not None and user.strip():
|
||||
row.win_admin_user = normalize_win_admin_user(user)
|
||||
if password is not None and password.strip():
|
||||
row.win_admin_password = password
|
||||
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return get_effective_win_admin_config(db)
|
||||
|
||||
|
||||
def get_effective_win_admin_for_host(db: Session, host: Host) -> WinAdminConfig:
|
||||
"""Prefer per-host mgmt_* when both user and password are set; else global Settings/env."""
|
||||
host_user = normalize_win_admin_user((host.mgmt_user or "").strip())
|
||||
host_password = (host.mgmt_password or "").strip()
|
||||
if host_user and host_password:
|
||||
return WinAdminConfig(user=host_user, password=host_password, source="host")
|
||||
|
||||
global_cfg = get_effective_win_admin_config(db)
|
||||
# Allow host to override only the username, still using global password (rare).
|
||||
if host_user and global_cfg.password.strip():
|
||||
return WinAdminConfig(user=host_user, password=global_cfg.password, source="host")
|
||||
return global_cfg
|
||||
@@ -0,0 +1,692 @@
|
||||
"""WinRM connectivity test for Windows hosts using domain admin credentials."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import socket
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import Host
|
||||
from app.services.agent_git_release import _cache_dir, clone_or_update_repo
|
||||
from app.services.rdp_bundle_delivery import build_rdp_bundle_zip, register_rdp_bundle_zip
|
||||
|
||||
_CLIXML_PROGRESS_NOISE = frozenset(
|
||||
{
|
||||
"Preparing modules for first use.",
|
||||
"Preparing modules fo",
|
||||
}
|
||||
)
|
||||
_CLIXML_MARKER = "#< CLIXML"
|
||||
_CYRILLIC_RE = re.compile(r"[\u0400-\u04FF]")
|
||||
RDP_REMOTE_STAGING_DIRNAME = "sac-rdp-staging"
|
||||
RDP_LEGACY_STAGING = r"C:\ProgramData\RDP-login-monitor\_sac_staging"
|
||||
RDP_BUNDLE_FILES = (
|
||||
"Login_Monitor.ps1",
|
||||
"Sac-Client.ps1",
|
||||
"RdpMonitor-TaskQuery.ps1",
|
||||
"version.txt",
|
||||
"Deploy-LoginMonitor.ps1",
|
||||
"Restart-RdpLoginMonitor.ps1",
|
||||
"login_monitor.settings.example.ps1",
|
||||
)
|
||||
RDP_BUNDLE_REQUIRED = frozenset(
|
||||
{
|
||||
"Login_Monitor.ps1",
|
||||
"Sac-Client.ps1",
|
||||
"version.txt",
|
||||
"Deploy-LoginMonitor.ps1",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class WinAdminNotConfiguredError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class HostNotWindowsError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class HostTargetMissingError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WinRmTestResult:
|
||||
ok: bool
|
||||
message: str
|
||||
target: str
|
||||
hostname: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WinRmCmdResult:
|
||||
ok: bool
|
||||
message: str
|
||||
target: str
|
||||
stdout: str = ""
|
||||
stderr: str = ""
|
||||
exit_code: int | None = None
|
||||
|
||||
|
||||
def _winrm_session(
|
||||
target: str,
|
||||
user: str,
|
||||
password: str,
|
||||
*,
|
||||
timeout_sec: int = 15,
|
||||
):
|
||||
from app.services.win_admin_settings import normalize_win_admin_user
|
||||
|
||||
user = normalize_win_admin_user(user.strip())
|
||||
target = target.strip()
|
||||
if not target or not user or not password:
|
||||
raise WinAdminNotConfiguredError("Windows admin credentials or target missing")
|
||||
|
||||
try:
|
||||
import winrm
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("pywinrm is not installed on SAC server") from exc
|
||||
|
||||
operation_timeout_sec = max(5, timeout_sec)
|
||||
read_timeout_sec = operation_timeout_sec + 15
|
||||
settings = get_settings()
|
||||
scheme = "https" if settings.sac_winrm_use_https else "http"
|
||||
port = 5986 if settings.sac_winrm_use_https else 5985
|
||||
endpoint = f"{scheme}://{target}:{port}/wsman"
|
||||
cert_validation = (settings.sac_winrm_server_cert_validation or "validate").strip().lower()
|
||||
if cert_validation not in {"validate", "ignore"}:
|
||||
cert_validation = "validate"
|
||||
session = winrm.Session(
|
||||
endpoint,
|
||||
auth=(user, password),
|
||||
transport="ntlm",
|
||||
read_timeout_sec=read_timeout_sec,
|
||||
operation_timeout_sec=operation_timeout_sec,
|
||||
server_cert_validation=cert_validation,
|
||||
)
|
||||
return session, winrm
|
||||
|
||||
|
||||
def _clixml_to_plain(text: str) -> str:
|
||||
"""Turn WinRM/PowerShell CLIXML blobs into readable text; drop progress noise."""
|
||||
raw = text or ""
|
||||
if _CLIXML_MARKER not in raw:
|
||||
return raw.strip()
|
||||
|
||||
messages = re.findall(r'<S N="Message">([^<]*)</S>', raw, flags=re.IGNORECASE)
|
||||
messages = [
|
||||
msg.strip()
|
||||
for msg in messages
|
||||
if msg.strip() and msg.strip() not in _CLIXML_PROGRESS_NOISE
|
||||
]
|
||||
to_strings = re.findall(r"<ToString>([^<]*)</ToString>", raw, flags=re.IGNORECASE)
|
||||
to_strings = [
|
||||
text.strip()
|
||||
for text in to_strings
|
||||
if text.strip() and "Preparing modules" not in text
|
||||
]
|
||||
parts = [*messages, *to_strings]
|
||||
if parts:
|
||||
return "\n".join(parts)
|
||||
|
||||
without = re.sub(r"#< CLIXML.*", "", raw, flags=re.DOTALL).strip()
|
||||
return without
|
||||
|
||||
|
||||
def _decode_text_score(text: str) -> int:
|
||||
cyrillic = len(_CYRILLIC_RE.findall(text))
|
||||
suspicious = len(re.findall(r"\?{3,}", text))
|
||||
return cyrillic * 5 - text.count("\ufffd") * 20 - suspicious * 15 - max(0, text.count("?") - cyrillic) * 2
|
||||
|
||||
|
||||
def _decode_winrm_bytes(data: bytes) -> str:
|
||||
if not data:
|
||||
return ""
|
||||
for encoding in ("utf-8-sig", "utf-8"):
|
||||
try:
|
||||
text = data.decode(encoding)
|
||||
except UnicodeDecodeError:
|
||||
break
|
||||
score = _decode_text_score(text)
|
||||
if text.isascii() or score > 0:
|
||||
return text
|
||||
best_score = -10_000
|
||||
best_text = ""
|
||||
for encoding in ("cp866", "cp1251"):
|
||||
try:
|
||||
text = data.decode(encoding)
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
score = _decode_text_score(text)
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_text = text
|
||||
if best_text:
|
||||
return best_text
|
||||
return data.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def _winrm_failure_detail(stdout: str, stderr: str, exit_code: int) -> str:
|
||||
stdout_plain = _clixml_to_plain(stdout)
|
||||
stderr_plain = _clixml_to_plain(stderr)
|
||||
|
||||
for text in (stderr_plain, stdout_plain):
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("ERROR:"):
|
||||
return stripped[6:].strip() or stripped
|
||||
|
||||
for candidate in (stderr_plain, stdout_plain):
|
||||
if candidate and _CLIXML_MARKER not in candidate:
|
||||
lines = [line.strip() for line in candidate.splitlines() if line.strip()]
|
||||
if lines:
|
||||
return lines[-1][:2000]
|
||||
|
||||
if stdout_plain:
|
||||
return stdout_plain[:2000]
|
||||
if stderr_plain:
|
||||
return stderr_plain[:2000]
|
||||
return (
|
||||
f"PowerShell exit code {exit_code} "
|
||||
"(CLIXML без текста ошибки — проверьте Deploy-LoginMonitor.ps1 на ПК)"
|
||||
)
|
||||
|
||||
|
||||
def _sanitize_winrm_streams(stdout: str, stderr: str) -> tuple[str, str]:
|
||||
return _clixml_to_plain(stdout), _clixml_to_plain(stderr)
|
||||
|
||||
|
||||
def _finalize_winrm_run(
|
||||
*,
|
||||
target: str,
|
||||
stdout: str,
|
||||
stderr: str,
|
||||
exit_code: int,
|
||||
) -> WinRmCmdResult:
|
||||
ok = exit_code == 0
|
||||
if ok:
|
||||
message = f"WinRM OK ({target}), exit 0"
|
||||
if stdout.strip():
|
||||
message = f"{message}\n{stdout.strip().splitlines()[0][:200]}"
|
||||
else:
|
||||
detail = _winrm_failure_detail(stdout, stderr, exit_code)
|
||||
message = f"WinRM command failed ({target}): {detail}"
|
||||
return WinRmCmdResult(
|
||||
ok=ok,
|
||||
message=message,
|
||||
target=target,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
exit_code=exit_code,
|
||||
)
|
||||
|
||||
|
||||
_SYSTEM32_CMDS = {
|
||||
"hostname": "hostname.exe",
|
||||
"qwinsta": "qwinsta.exe",
|
||||
"logoff": "logoff.exe",
|
||||
}
|
||||
|
||||
|
||||
def _resolve_winrm_cmd(remote_cmd: str) -> str:
|
||||
"""WinRM cmd shell may have minimal PATH — use explicit System32 for built-ins."""
|
||||
text = remote_cmd.strip()
|
||||
if not text:
|
||||
return text
|
||||
parts = text.split(None, 1)
|
||||
name = parts[0].lower()
|
||||
rest = parts[1] if len(parts) > 1 else ""
|
||||
exe = _SYSTEM32_CMDS.get(name)
|
||||
if exe:
|
||||
suffix = f" {rest}" if rest else ""
|
||||
return f"%SystemRoot%\\System32\\{exe}{suffix}"
|
||||
return text
|
||||
|
||||
|
||||
def run_winrm_ps(
|
||||
*,
|
||||
target: str,
|
||||
user: str,
|
||||
password: str,
|
||||
script: str,
|
||||
timeout_sec: int = 30,
|
||||
) -> WinRmCmdResult:
|
||||
target = target.strip()
|
||||
try:
|
||||
session, winrm = _winrm_session(target, user, password, timeout_sec=timeout_sec)
|
||||
result = session.run_ps(script)
|
||||
except winrm.exceptions.WinRMTransportError as exc:
|
||||
detail = str(exc)
|
||||
hint = ""
|
||||
if "credentials were rejected" in detail.lower():
|
||||
hint = " Проверьте логин (B26\\user), пароль и WinRM на ПК."
|
||||
return WinRmCmdResult(
|
||||
ok=False,
|
||||
message=f"WinRM transport error ({target}): {detail}{hint}",
|
||||
target=target,
|
||||
)
|
||||
except (socket.timeout, TimeoutError):
|
||||
return WinRmCmdResult(
|
||||
ok=False,
|
||||
message=f"WinRM timeout ({timeout_sec}s) to {target}:5985",
|
||||
target=target,
|
||||
)
|
||||
except WinAdminNotConfiguredError as exc:
|
||||
return WinRmCmdResult(ok=False, message=str(exc), target=target)
|
||||
except RuntimeError as exc:
|
||||
return WinRmCmdResult(ok=False, message=str(exc), target=target)
|
||||
except Exception as exc:
|
||||
return WinRmCmdResult(ok=False, message=f"WinRM error ({target}): {exc}", target=target)
|
||||
|
||||
stdout = _decode_winrm_bytes(result.std_out or b"")
|
||||
stderr = _decode_winrm_bytes(result.std_err or b"")
|
||||
exit_code = int(result.status_code)
|
||||
stdout, stderr = _sanitize_winrm_streams(stdout, stderr)
|
||||
return _finalize_winrm_run(
|
||||
target=target,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
exit_code=exit_code,
|
||||
)
|
||||
|
||||
|
||||
def run_winrm_cmd(
|
||||
*,
|
||||
target: str,
|
||||
user: str,
|
||||
password: str,
|
||||
remote_cmd: str,
|
||||
timeout_sec: int = 30,
|
||||
) -> WinRmCmdResult:
|
||||
target = target.strip()
|
||||
try:
|
||||
session, winrm = _winrm_session(target, user, password, timeout_sec=timeout_sec)
|
||||
result = session.run_cmd(_resolve_winrm_cmd(remote_cmd))
|
||||
except winrm.exceptions.WinRMTransportError as exc:
|
||||
detail = str(exc)
|
||||
hint = ""
|
||||
if "credentials were rejected" in detail.lower():
|
||||
hint = " Проверьте логин (B26\\user), пароль и WinRM на ПК."
|
||||
return WinRmCmdResult(
|
||||
ok=False,
|
||||
message=f"WinRM transport error ({target}): {detail}{hint}",
|
||||
target=target,
|
||||
)
|
||||
except (socket.timeout, TimeoutError):
|
||||
return WinRmCmdResult(
|
||||
ok=False,
|
||||
message=f"WinRM timeout ({timeout_sec}s) to {target}:5985",
|
||||
target=target,
|
||||
)
|
||||
except WinAdminNotConfiguredError as exc:
|
||||
return WinRmCmdResult(ok=False, message=str(exc), target=target)
|
||||
except RuntimeError as exc:
|
||||
return WinRmCmdResult(ok=False, message=str(exc), target=target)
|
||||
except Exception as exc:
|
||||
return WinRmCmdResult(ok=False, message=f"WinRM error ({target}): {exc}", target=target)
|
||||
|
||||
stdout = _decode_winrm_bytes(result.std_out or b"")
|
||||
stderr = _decode_winrm_bytes(result.std_err or b"")
|
||||
exit_code = int(result.status_code)
|
||||
stdout, stderr = _sanitize_winrm_streams(stdout, stderr)
|
||||
return _finalize_winrm_run(
|
||||
target=target,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
exit_code=exit_code,
|
||||
)
|
||||
|
||||
|
||||
def run_winrm_qwinsta(*, target: str, user: str, password: str) -> WinRmCmdResult:
|
||||
return run_winrm_cmd(target=target, user=user, password=password, remote_cmd="qwinsta", timeout_sec=45)
|
||||
|
||||
|
||||
def run_winrm_logoff(*, target: str, user: str, password: str, session_id: int) -> WinRmCmdResult:
|
||||
if session_id < 0:
|
||||
return WinRmCmdResult(ok=False, message="Invalid session_id", target=target)
|
||||
return run_winrm_cmd(
|
||||
target=target,
|
||||
user=user,
|
||||
password=password,
|
||||
remote_cmd=f"logoff {session_id} /v",
|
||||
timeout_sec=45,
|
||||
)
|
||||
|
||||
|
||||
def _powershell_script_prelude() -> str:
|
||||
return (
|
||||
"$ProgressPreference = 'SilentlyContinue'\n"
|
||||
"$InformationPreference = 'SilentlyContinue'\n"
|
||||
"$utf8NoBom = New-Object System.Text.UTF8Encoding $false\n"
|
||||
"[Console]::OutputEncoding = $utf8NoBom\n"
|
||||
"$OutputEncoding = $utf8NoBom\n"
|
||||
)
|
||||
|
||||
|
||||
def _powershell_literal_path(path: str) -> str:
|
||||
return path.replace("'", "''")
|
||||
|
||||
|
||||
def _wrap_powershell_body(body: str) -> str:
|
||||
indented = "\n".join(f" {line}" if line else "" for line in body.splitlines())
|
||||
return (
|
||||
_powershell_script_prelude()
|
||||
+ "try {\n"
|
||||
+ indented
|
||||
+ "\n} catch {\n"
|
||||
+ " Write-Output ('ERROR: ' + $_.Exception.Message)\n"
|
||||
+ " exit 1\n"
|
||||
+ "}\n"
|
||||
)
|
||||
|
||||
|
||||
def _custom_deploy_body(script_path: str) -> str:
|
||||
literal = _powershell_literal_path(script_path.strip())
|
||||
return (
|
||||
f"$p = '{literal}'\n"
|
||||
"if (-not (Test-Path -LiteralPath $p)) { throw \"Deploy script not found: $p\" }\n"
|
||||
"Write-Output \"Running: $p\"\n"
|
||||
"& $p"
|
||||
)
|
||||
|
||||
|
||||
def _rdp_staging_setup_ps() -> str:
|
||||
dirname = _powershell_literal_path(RDP_REMOTE_STAGING_DIRNAME)
|
||||
legacy = _powershell_literal_path(RDP_LEGACY_STAGING)
|
||||
return (
|
||||
f"$staging = Join-Path $env:TEMP '{dirname}'\n"
|
||||
f"$legacyStaging = '{legacy}'\n"
|
||||
"if (Test-Path -LiteralPath $legacyStaging) {\n"
|
||||
" Remove-Item -LiteralPath $legacyStaging -Recurse -Force -ErrorAction SilentlyContinue\n"
|
||||
"}\n"
|
||||
)
|
||||
|
||||
|
||||
def _prepare_staging_body() -> str:
|
||||
return (
|
||||
_rdp_staging_setup_ps()
|
||||
+ "if (Test-Path -LiteralPath $staging) { Remove-Item -LiteralPath $staging -Recurse -Force }\n"
|
||||
+ "New-Item -ItemType Directory -Path $staging -Force | Out-Null\n"
|
||||
+ "Write-Output \"Staging ready: $staging\"\n"
|
||||
)
|
||||
|
||||
|
||||
def _deploy_from_staging_body() -> str:
|
||||
return (
|
||||
_rdp_staging_setup_ps()
|
||||
+ "$deploy = Join-Path $staging 'Deploy-LoginMonitor.ps1'\n"
|
||||
+ "if (-not (Test-Path -LiteralPath $staging)) { throw 'Staging directory missing' }\n"
|
||||
+ "if (-not (Test-Path -LiteralPath $deploy)) { throw \"Deploy-LoginMonitor.ps1 missing in staging\" }\n"
|
||||
+ "$logPath = Join-Path $env:ProgramData 'RDP-login-monitor\\Logs\\deploy.log'\n"
|
||||
+ "Write-Output \"Running: $deploy -SourceShareRoot $staging\"\n"
|
||||
+ "powershell.exe -NoProfile -ExecutionPolicy Bypass -File $deploy -SourceShareRoot $staging\n"
|
||||
+ "if (Test-Path -LiteralPath $logPath) {\n"
|
||||
+ " Write-Output ''\n"
|
||||
+ " Write-Output '--- deploy.log ---'\n"
|
||||
+ " Get-Content -LiteralPath $logPath -Encoding UTF8 | Select-Object -Last 60\n"
|
||||
+ "}\n"
|
||||
)
|
||||
|
||||
|
||||
def _bundle_download_url(token: str) -> str:
|
||||
settings = get_settings()
|
||||
base = (settings.sac_agent_bundle_base_url or settings.sac_public_url).rstrip("/")
|
||||
return f"{base}/api/v1/agent/rdp-bundle/{token}"
|
||||
|
||||
|
||||
def _download_bundle_body(bundle_url: str) -> str:
|
||||
url = _powershell_literal_path(bundle_url)
|
||||
return (
|
||||
_rdp_staging_setup_ps()
|
||||
+ "if (-not (Test-Path -LiteralPath $staging)) { throw 'Staging directory missing' }\n"
|
||||
+ f"$url = '{url}'\n"
|
||||
+ "$zip = Join-Path $env:TEMP ('sac-rdp-bundle-' + [Guid]::NewGuid().ToString() + '.zip')\n"
|
||||
+ "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12\n"
|
||||
+ "Write-Output \"Downloading bundle: $url\"\n"
|
||||
+ "Invoke-WebRequest -Uri $url -OutFile $zip -UseBasicParsing\n"
|
||||
+ "if (-not (Test-Path -LiteralPath $zip)) { throw 'Bundle download produced no file' }\n"
|
||||
+ "$zipSize = (Get-Item -LiteralPath $zip).Length\n"
|
||||
+ "if ($zipSize -lt 100) { throw \"Bundle too small ($zipSize bytes)\" }\n"
|
||||
+ "Add-Type -AssemblyName System.IO.Compression.FileSystem\n"
|
||||
+ "[System.IO.Compression.ZipFile]::ExtractToDirectory($zip, $staging)\n"
|
||||
+ "Remove-Item -LiteralPath $zip -Force -ErrorAction SilentlyContinue\n"
|
||||
+ "Write-Output \"Bundle extracted to $staging\"\n"
|
||||
)
|
||||
|
||||
|
||||
def _fetch_rdp_bundle_dir(repo_url: str, git_branch: str) -> Path:
|
||||
settings = get_settings()
|
||||
return clone_or_update_repo(
|
||||
repo_url,
|
||||
git_branch,
|
||||
_cache_dir(),
|
||||
token=(settings.sac_agent_git_token or "").strip(),
|
||||
)
|
||||
|
||||
|
||||
def run_winrm_rdp_monitor_update(
|
||||
*,
|
||||
target: str,
|
||||
user: str,
|
||||
password: str,
|
||||
script_path: str = "",
|
||||
repo_url: str = "",
|
||||
git_branch: str = "main",
|
||||
) -> WinRmCmdResult:
|
||||
target = target.strip()
|
||||
if script_path.strip():
|
||||
return run_winrm_ps(
|
||||
target=target,
|
||||
user=user,
|
||||
password=password,
|
||||
script=_wrap_powershell_body(_custom_deploy_body(script_path)),
|
||||
timeout_sec=900,
|
||||
)
|
||||
|
||||
effective_repo = (
|
||||
repo_url or "https://git.kalinamall.ru/PapaTramp/RDP-login-monitor.git"
|
||||
).strip()
|
||||
effective_branch = (git_branch or "main").strip() or "main"
|
||||
if not effective_repo:
|
||||
return WinRmCmdResult(
|
||||
ok=False,
|
||||
message="rdp_git_repo_url is not configured in SAC",
|
||||
target=target,
|
||||
)
|
||||
|
||||
try:
|
||||
repo_dir = _fetch_rdp_bundle_dir(effective_repo, effective_branch)
|
||||
except Exception as exc:
|
||||
return WinRmCmdResult(
|
||||
ok=False,
|
||||
message=f"SAC failed to fetch RDP bundle from git: {exc}",
|
||||
target=target,
|
||||
)
|
||||
|
||||
missing = [name for name in RDP_BUNDLE_REQUIRED if not (repo_dir / name).is_file()]
|
||||
if missing:
|
||||
return WinRmCmdResult(
|
||||
ok=False,
|
||||
message=f"RDP bundle incomplete on SAC: missing {', '.join(missing)}",
|
||||
target=target,
|
||||
)
|
||||
|
||||
zip_bytes = build_rdp_bundle_zip(repo_dir, RDP_BUNDLE_FILES)
|
||||
if not zip_bytes:
|
||||
return WinRmCmdResult(
|
||||
ok=False,
|
||||
message="RDP bundle zip is empty on SAC",
|
||||
target=target,
|
||||
)
|
||||
bundle_token = register_rdp_bundle_zip(zip_bytes)
|
||||
bundle_url = _bundle_download_url(bundle_token)
|
||||
staging_label = f"%TEMP%\\{RDP_REMOTE_STAGING_DIRNAME}"
|
||||
|
||||
prep = run_winrm_ps(
|
||||
target=target,
|
||||
user=user,
|
||||
password=password,
|
||||
script=_wrap_powershell_body(_prepare_staging_body()),
|
||||
timeout_sec=120,
|
||||
)
|
||||
if not prep.ok:
|
||||
return prep
|
||||
|
||||
download = run_winrm_ps(
|
||||
target=target,
|
||||
user=user,
|
||||
password=password,
|
||||
script=_wrap_powershell_body(_download_bundle_body(bundle_url)),
|
||||
timeout_sec=300,
|
||||
)
|
||||
if not download.ok:
|
||||
return WinRmCmdResult(
|
||||
ok=False,
|
||||
message=(
|
||||
f"Failed to download RDP bundle on client: {download.message} "
|
||||
"(bundle URL omitted from logs)"
|
||||
),
|
||||
target=target,
|
||||
stdout="\n\n".join(part.strip() for part in [prep.stdout, download.stdout] if part.strip()),
|
||||
stderr=download.stderr,
|
||||
exit_code=download.exit_code,
|
||||
)
|
||||
|
||||
deploy = run_winrm_ps(
|
||||
target=target,
|
||||
user=user,
|
||||
password=password,
|
||||
script=_wrap_powershell_body(_deploy_from_staging_body()),
|
||||
timeout_sec=900,
|
||||
)
|
||||
header = f"SAC served bundle from git; client staging {staging_label}"
|
||||
stdout = "\n\n".join(
|
||||
part.strip()
|
||||
for part in [header, prep.stdout, download.stdout, deploy.stdout]
|
||||
if part.strip()
|
||||
)
|
||||
return WinRmCmdResult(
|
||||
ok=deploy.ok,
|
||||
message=deploy.message,
|
||||
target=target,
|
||||
stdout=stdout,
|
||||
stderr=deploy.stderr,
|
||||
exit_code=deploy.exit_code,
|
||||
)
|
||||
|
||||
|
||||
def run_winrm_on_host_targets(
|
||||
host: Host,
|
||||
*,
|
||||
user: str,
|
||||
password: str,
|
||||
action,
|
||||
) -> tuple[WinRmCmdResult | None, list[str]]:
|
||||
"""Try WinRM targets in hostname-first order; action(target) -> WinRmCmdResult."""
|
||||
attempts: list[str] = []
|
||||
last: WinRmCmdResult | None = None
|
||||
for target in iter_winrm_targets(host):
|
||||
result = action(target=target)
|
||||
last = result
|
||||
attempts.append(f"{target}={'OK' if result.ok else 'fail'}")
|
||||
if result.ok:
|
||||
return result, attempts
|
||||
return last, attempts
|
||||
|
||||
|
||||
def resolve_windows_host_target(host: Host) -> str:
|
||||
targets = iter_winrm_targets(host)
|
||||
if not targets:
|
||||
raise HostTargetMissingError("Host has no hostname or IPv4 for WinRM test")
|
||||
return targets[0]
|
||||
|
||||
|
||||
def _netbios_name_variants(name: str | None) -> list[str]:
|
||||
"""Короткое имя ПК (NetBIOS), как Enter-PSSession -ComputerName Andrisonova-PC."""
|
||||
text = (name or "").strip()
|
||||
if not text:
|
||||
return []
|
||||
short = text.split(".")[0].split("\\")[0].strip()
|
||||
if not short:
|
||||
return []
|
||||
if short.casefold() == text.casefold():
|
||||
return [short]
|
||||
return [short, text]
|
||||
|
||||
|
||||
def _looks_like_computer_name(value: str) -> bool:
|
||||
text = value.strip()
|
||||
if not text or " " in text:
|
||||
return False
|
||||
return len(text) <= 63
|
||||
|
||||
|
||||
def iter_winrm_targets(host: Host) -> list[str]:
|
||||
"""WinRM + NTLM: сначала имя хоста (как Enter-PSSession -ComputerName), IP — последним."""
|
||||
if not is_windows_host(host):
|
||||
raise HostNotWindowsError("Host is not Windows")
|
||||
|
||||
seen: set[str] = set()
|
||||
ordered: list[str] = []
|
||||
|
||||
def add(value: str | None) -> None:
|
||||
text = (value or "").strip()
|
||||
if not text or text.casefold() in seen:
|
||||
return
|
||||
seen.add(text.casefold())
|
||||
ordered.append(text)
|
||||
|
||||
for variant in _netbios_name_variants(host.hostname):
|
||||
add(variant)
|
||||
|
||||
inventory = host.inventory if isinstance(host.inventory, dict) else {}
|
||||
computer_name = inventory.get("computer_name")
|
||||
if isinstance(computer_name, str):
|
||||
for variant in _netbios_name_variants(computer_name):
|
||||
add(variant)
|
||||
|
||||
if host.display_name and _looks_like_computer_name(host.display_name):
|
||||
for variant in _netbios_name_variants(host.display_name):
|
||||
add(variant)
|
||||
|
||||
add(host.ipv4)
|
||||
return ordered
|
||||
|
||||
|
||||
def is_windows_host(host: Host) -> bool:
|
||||
if (host.os_family or "").strip().lower() == "windows":
|
||||
return True
|
||||
return (host.product or "").strip() == "rdp-login-monitor"
|
||||
|
||||
|
||||
def test_winrm_connection(
|
||||
*,
|
||||
target: str,
|
||||
user: str,
|
||||
password: str,
|
||||
timeout_sec: int = 15,
|
||||
) -> WinRmTestResult:
|
||||
result = run_winrm_cmd(
|
||||
target=target,
|
||||
user=user,
|
||||
password=password,
|
||||
remote_cmd="hostname",
|
||||
timeout_sec=timeout_sec,
|
||||
)
|
||||
if result.ok and result.stdout.strip():
|
||||
host = result.stdout.strip().splitlines()[0]
|
||||
return WinRmTestResult(
|
||||
ok=True,
|
||||
message=f"WinRM OK, hostname={host}",
|
||||
target=result.target,
|
||||
hostname=host,
|
||||
)
|
||||
return WinRmTestResult(
|
||||
ok=result.ok,
|
||||
message=result.message,
|
||||
target=result.target,
|
||||
hostname=None,
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Escape user input for SQL ILIKE patterns."""
|
||||
|
||||
|
||||
def escape_ilike_pattern(value: str) -> str:
|
||||
text = (value or "").strip()
|
||||
if not text:
|
||||
return text
|
||||
return text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
|
||||
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
|
||||
|
||||
APP_NAME = "Security Alert Center"
|
||||
APP_VERSION = "0.9.9"
|
||||
APP_VERSION = "0.5.18"
|
||||
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
||||
|
||||
@@ -13,6 +13,8 @@ httpx>=0.28.0,<1
|
||||
google-auth>=2.36.0,<3
|
||||
requests>=2.32.0,<3
|
||||
psutil>=6.1.0,<8
|
||||
pywinrm>=0.5.0,<1
|
||||
paramiko>=3.5.0,<4
|
||||
|
||||
# dev
|
||||
pytest>=8.3.0,<9
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
"""SQLite in-memory fixtures for API tests."""
|
||||
"""SQLite in-memory fixtures for API tests."""
|
||||
|
||||
import os
|
||||
|
||||
# Must be set before app.database imports create_engine
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
|
||||
os.environ.setdefault("SAC_BOOTSTRAP_API_KEY", "sac_test_key_for_pytest_only")
|
||||
os.environ.setdefault("SAC_SECURITY_ENFORCE", "false")
|
||||
os.environ.setdefault("JWT_SECRET", "pytest-jwt-secret-not-for-production-use")
|
||||
os.environ.setdefault("SAC_SSH_AUTO_ADD_HOST_KEY", "true")
|
||||
os.environ.setdefault("SAC_HOST_SILENCE_SCAN_ENABLED", "false")
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
@@ -80,16 +84,57 @@ def db_session(db_engine):
|
||||
session.close()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_agent_git_release(monkeypatch):
|
||||
"""API tests must not clone real git repos."""
|
||||
from app.services.agent_git_release import GitReleaseVersions
|
||||
|
||||
empty = GitReleaseVersions(versions={}, fetched_at=None, from_cache=True)
|
||||
monkeypatch.setattr(
|
||||
"app.services.agent_git_release.get_git_release_versions",
|
||||
lambda cfg, **kwargs: empty,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.api.v1.hosts.get_git_release_versions",
|
||||
lambda cfg, **kwargs: empty,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.api.v1.settings.get_git_release_versions",
|
||||
lambda cfg, **kwargs: empty,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.agent_update.get_git_release_versions",
|
||||
lambda cfg, **kwargs: empty,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_remote_action_state():
|
||||
from app.services import host_remote_actions
|
||||
|
||||
with host_remote_actions._lock:
|
||||
host_remote_actions._running.clear()
|
||||
yield
|
||||
with host_remote_actions._lock:
|
||||
host_remote_actions._running.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(db_session, monkeypatch):
|
||||
def client(db_session, db_engine, monkeypatch):
|
||||
monkeypatch.setenv("SAC_REMOTE_ACTION_INLINE", "1")
|
||||
monkeypatch.setattr("app.main.bootstrap_api_key", lambda: None)
|
||||
monkeypatch.setattr("app.main.bootstrap_users", lambda: None)
|
||||
monkeypatch.setattr("app.main.bootstrap_stale_remote_actions", lambda: None)
|
||||
|
||||
test_session_local = sessionmaker(bind=db_engine, autocommit=False, autoflush=False)
|
||||
monkeypatch.setattr("app.services.host_remote_actions.SessionLocal", test_session_local)
|
||||
|
||||
def override_get_db():
|
||||
session = test_session_local()
|
||||
try:
|
||||
yield db_session
|
||||
yield session
|
||||
finally:
|
||||
pass
|
||||
session.close()
|
||||
|
||||
fastapi_app.dependency_overrides[get_db] = override_get_db
|
||||
with TestClient(fastapi_app) as c:
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Tests for git-based agent release version resolution."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from app.services.agent_git_release import (
|
||||
normalize_git_repo_url,
|
||||
parse_product_version_from_dir,
|
||||
parse_rdp_version_from_files,
|
||||
parse_ssh_version_from_files,
|
||||
recommended_from_git,
|
||||
)
|
||||
from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH, AgentUpdateConfig
|
||||
from app.services.agent_version import reference_agent_versions_by_product
|
||||
|
||||
|
||||
def test_normalize_git_repo_url():
|
||||
assert normalize_git_repo_url("git.kalinamall.ru/PapaTramp/ssh-monitor").endswith(
|
||||
"ssh-monitor.git"
|
||||
)
|
||||
assert normalize_git_repo_url("https://example.com/repo.git") == "https://example.com/repo.git"
|
||||
|
||||
|
||||
def test_parse_rdp_version_from_files():
|
||||
assert parse_rdp_version_from_files("2.1.2-SAC\n", None) == "2.1.2-SAC"
|
||||
assert (
|
||||
parse_rdp_version_from_files(None, '$ScriptVersion = "2.0.35-SAC"')
|
||||
== "2.0.35-SAC"
|
||||
)
|
||||
|
||||
|
||||
def test_parse_ssh_version_from_files():
|
||||
assert parse_ssh_version_from_files('SSH_MONITOR_VERSION="2.1.0-SAC"\n', None) == "2.1.0-SAC"
|
||||
assert parse_ssh_version_from_files(None, "2.0.6-SAC") == "2.0.6-SAC"
|
||||
|
||||
|
||||
def test_parse_product_version_from_dir(tmp_path: Path):
|
||||
(tmp_path / "version.txt").write_text("2.1.2-SAC\n", encoding="utf-8")
|
||||
assert parse_product_version_from_dir(tmp_path, PRODUCT_RDP) == "2.1.2-SAC"
|
||||
|
||||
ssh_dir = tmp_path / "ssh"
|
||||
ssh_dir.mkdir()
|
||||
(ssh_dir / "ssh-monitor").write_text('SSH_MONITOR_VERSION="2.1.0-SAC"\n', encoding="utf-8")
|
||||
assert parse_product_version_from_dir(ssh_dir, PRODUCT_SSH) == "2.1.0-SAC"
|
||||
|
||||
|
||||
def test_recommended_from_git_prefers_git():
|
||||
cfg = AgentUpdateConfig(
|
||||
mode="sac",
|
||||
fallback_enabled=True,
|
||||
fallback_after_minutes=15,
|
||||
recommended_rdp_version="2.0.0-SAC",
|
||||
recommended_ssh_version="",
|
||||
min_rdp_version="",
|
||||
min_ssh_version="",
|
||||
win_agent_update_script="",
|
||||
rdp_git_repo_url="https://git.example/rdp.git",
|
||||
ssh_git_repo_url="https://git.example/ssh.git",
|
||||
git_branch="main",
|
||||
source="env",
|
||||
)
|
||||
git_versions = {PRODUCT_SSH: "2.1.0-SAC"}
|
||||
assert recommended_from_git(cfg, git_versions, PRODUCT_SSH) == "2.1.0-SAC"
|
||||
assert recommended_from_git(cfg, git_versions, PRODUCT_RDP) == "2.0.0-SAC"
|
||||
|
||||
|
||||
def test_reference_agent_versions_uses_git():
|
||||
cfg = AgentUpdateConfig(
|
||||
mode="gpo",
|
||||
fallback_enabled=True,
|
||||
fallback_after_minutes=15,
|
||||
recommended_rdp_version="",
|
||||
recommended_ssh_version="",
|
||||
min_rdp_version="",
|
||||
min_ssh_version="",
|
||||
win_agent_update_script="",
|
||||
rdp_git_repo_url="",
|
||||
ssh_git_repo_url="",
|
||||
git_branch="main",
|
||||
source="env",
|
||||
)
|
||||
refs = reference_agent_versions_by_product(
|
||||
cfg,
|
||||
{"ssh-monitor": "2.0.6-SAC"},
|
||||
git_versions={"ssh-monitor": "2.1.0-SAC"},
|
||||
)
|
||||
assert refs["ssh-monitor"] == "2.1.0-SAC"
|
||||
@@ -0,0 +1,292 @@
|
||||
"""Tests for agent update settings, poll, request and ingest lifecycle."""
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.models import Host
|
||||
from app.services.agent_poll import build_agent_poll_payload
|
||||
from app.services.agent_update import (
|
||||
AGENT_UPDATE_SUCCESS,
|
||||
AgentUpdateNotAllowedError,
|
||||
process_agent_update_ingest,
|
||||
request_agent_update,
|
||||
)
|
||||
from app.services.agent_update_settings import upsert_agent_update_settings
|
||||
from app.services.ingest import ingest_event
|
||||
from tests.test_ingest import VALID_EVENT
|
||||
|
||||
|
||||
def wait_remote_job(client, host_id, headers, timeout=5.0):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
response = client.get(
|
||||
f"/api/v1/hosts/{host_id}/actions/remote-job",
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
if not body.get("active") and body.get("status") != "running":
|
||||
return body
|
||||
time.sleep(0.05)
|
||||
raise AssertionError("remote job did not finish in time")
|
||||
|
||||
|
||||
def test_agent_update_settings_sac_mode(db_session):
|
||||
cfg = upsert_agent_update_settings(
|
||||
db_session,
|
||||
mode="sac",
|
||||
recommended_ssh_version="2.1.0-SAC",
|
||||
fallback_after_minutes=10,
|
||||
)
|
||||
assert cfg.mode == "sac"
|
||||
assert cfg.recommended_ssh_version == "2.1.0-SAC"
|
||||
assert cfg.fallback_after_minutes == 10
|
||||
|
||||
|
||||
def test_request_agent_update_sets_pending(db_session):
|
||||
upsert_agent_update_settings(db_session, mode="sac", recommended_ssh_version="2.1.0-SAC")
|
||||
host = Host(
|
||||
hostname="linux-1",
|
||||
os_family="linux",
|
||||
product="ssh-monitor",
|
||||
product_version="2.0.0-SAC",
|
||||
agent_instance_id="agent-uuid-1",
|
||||
)
|
||||
db_session.add(host)
|
||||
db_session.commit()
|
||||
|
||||
request_agent_update(db_session, host)
|
||||
assert host.pending_agent_update is True
|
||||
assert host.pending_update_target_version == "2.1.0-SAC"
|
||||
assert host.agent_update_state == "requested"
|
||||
|
||||
|
||||
def test_request_agent_update_blocked_in_gpo_mode(db_session):
|
||||
upsert_agent_update_settings(db_session, mode="gpo")
|
||||
host = Host(
|
||||
hostname="linux-2",
|
||||
os_family="linux",
|
||||
product="ssh-monitor",
|
||||
agent_instance_id="agent-uuid-2",
|
||||
)
|
||||
db_session.add(host)
|
||||
db_session.commit()
|
||||
|
||||
try:
|
||||
request_agent_update(db_session, host)
|
||||
raise AssertionError("expected AgentUpdateNotAllowedError")
|
||||
except AgentUpdateNotAllowedError:
|
||||
pass
|
||||
|
||||
|
||||
def test_poll_payload_includes_update_and_config(db_session):
|
||||
upsert_agent_update_settings(db_session, mode="sac")
|
||||
host = Host(
|
||||
hostname="win-1",
|
||||
os_family="windows",
|
||||
product="rdp-login-monitor",
|
||||
agent_instance_id="agent-uuid-3",
|
||||
pending_agent_update=True,
|
||||
pending_update_target_version="2.1.0-SAC",
|
||||
agent_config={"ServerDisplayName": "Test PC"},
|
||||
agent_config_revision=3,
|
||||
)
|
||||
db_session.add(host)
|
||||
db_session.commit()
|
||||
|
||||
payload = build_agent_poll_payload(db_session, host)
|
||||
assert payload["config_revision"] == 3
|
||||
assert payload["config"]["settings"]["ServerDisplayName"] == "Test PC"
|
||||
assert payload["update"]["requested"] is True
|
||||
assert payload["update"]["target_version"] == "2.1.0-SAC"
|
||||
assert payload["update"]["source"] == "sac"
|
||||
|
||||
|
||||
def test_ingest_agent_update_success_clears_pending(db_session):
|
||||
host = Host(
|
||||
hostname="linux-3",
|
||||
os_family="linux",
|
||||
product="ssh-monitor",
|
||||
agent_instance_id="agent-uuid-4",
|
||||
pending_agent_update=True,
|
||||
pending_update_target_version="2.1.0-SAC",
|
||||
)
|
||||
db_session.add(host)
|
||||
db_session.commit()
|
||||
|
||||
process_agent_update_ingest(
|
||||
db_session,
|
||||
host,
|
||||
AGENT_UPDATE_SUCCESS,
|
||||
{"product_version": "2.1.0-SAC"},
|
||||
)
|
||||
assert host.pending_agent_update is False
|
||||
assert host.product_version == "2.1.0-SAC"
|
||||
assert host.agent_update_state == "success"
|
||||
|
||||
|
||||
def test_api_request_agent_update(jwt_headers, client, db_session):
|
||||
upsert_agent_update_settings(db_session, mode="sac", recommended_ssh_version="2.1.0-SAC")
|
||||
host = Host(
|
||||
hostname="api-linux",
|
||||
os_family="linux",
|
||||
product="ssh-monitor",
|
||||
agent_instance_id="agent-api-1",
|
||||
)
|
||||
db_session.add(host)
|
||||
db_session.commit()
|
||||
|
||||
response = client.post(f"/api/v1/hosts/{host.id}/actions/request-agent-update", headers=jwt_headers)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["pending_agent_update"] is True
|
||||
assert body["target_version"] == "2.1.0-SAC"
|
||||
|
||||
|
||||
def test_api_agent_poll_extended(auth_headers, client, db_session):
|
||||
upsert_agent_update_settings(db_session, mode="sac")
|
||||
host = Host(
|
||||
hostname="poll-host",
|
||||
os_family="linux",
|
||||
product="ssh-monitor",
|
||||
agent_instance_id="poll-agent-id",
|
||||
pending_agent_update=True,
|
||||
pending_update_target_version="2.1.0-SAC",
|
||||
)
|
||||
db_session.add(host)
|
||||
db_session.commit()
|
||||
|
||||
response = client.get(
|
||||
"/api/v1/agent/commands",
|
||||
params={"agent_instance_id": "poll-agent-id"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["update"]["requested"] is True
|
||||
assert body["commands"] == []
|
||||
|
||||
|
||||
def test_api_patch_agent_config(jwt_headers, client, db_session):
|
||||
host = Host(
|
||||
hostname="cfg-host",
|
||||
os_family="windows",
|
||||
product="rdp-login-monitor",
|
||||
)
|
||||
db_session.add(host)
|
||||
db_session.commit()
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1/hosts/{host.id}/agent-config",
|
||||
json={"settings": {"ServerDisplayName": "UNMS Kalina", "GetInventory": True}},
|
||||
headers=jwt_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["config_revision"] == 1
|
||||
assert body["settings"]["ServerDisplayName"] == "UNMS Kalina"
|
||||
|
||||
|
||||
def test_api_agent_update_fallback_ssh(jwt_headers, client, db_session, monkeypatch):
|
||||
monkeypatch.setenv("SAC_LINUX_ADMIN_USER", "root")
|
||||
monkeypatch.setenv("SAC_LINUX_ADMIN_PASSWORD", "pw")
|
||||
from app.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
|
||||
host = Host(
|
||||
hostname="fb-linux",
|
||||
os_family="linux",
|
||||
product="ssh-monitor",
|
||||
ipv4="10.10.36.9",
|
||||
)
|
||||
db_session.add(host)
|
||||
db_session.commit()
|
||||
|
||||
with patch("app.services.agent_update.run_ssh_monitor_update") as mock_update:
|
||||
from app.services.ssh_connect import SshCommandResult
|
||||
|
||||
mock_update.return_value = SshCommandResult(
|
||||
ok=True,
|
||||
message="updated",
|
||||
target="fb-linux",
|
||||
agent_version="2.1.0-SAC",
|
||||
)
|
||||
response = client.post(
|
||||
f"/api/v1/hosts/{host.id}/actions/agent-update-fallback",
|
||||
headers=jwt_headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 202
|
||||
assert response.json()["status"] == "running"
|
||||
job = wait_remote_job(client, host.id, jwt_headers)
|
||||
assert job["ok"] is True
|
||||
assert job["product_version"] == "2.1.0-SAC"
|
||||
|
||||
|
||||
def test_clear_stale_running_remote_actions(db_session):
|
||||
from app.services.host_remote_actions import clear_stale_running_remote_actions
|
||||
|
||||
host = Host(
|
||||
hostname="stale-win",
|
||||
os_family="windows",
|
||||
product="rdp-login-monitor",
|
||||
agent_update_state="running",
|
||||
remote_action={"status": "running", "message": "Подключение…"},
|
||||
)
|
||||
db_session.add(host)
|
||||
db_session.commit()
|
||||
|
||||
cleared = clear_stale_running_remote_actions(db_session, reason="test reset")
|
||||
assert cleared == ["stale-win"]
|
||||
db_session.refresh(host)
|
||||
assert host.agent_update_state == "failed"
|
||||
assert host.remote_action["status"] == "failed"
|
||||
assert host.remote_action["ok"] is False
|
||||
|
||||
|
||||
def test_get_remote_action_status_ignores_stale_running_payload(db_session):
|
||||
from app.services.host_remote_actions import get_remote_action_status
|
||||
|
||||
host = Host(
|
||||
hostname="done-linux",
|
||||
os_family="linux",
|
||||
product="ssh-monitor",
|
||||
agent_update_state="success",
|
||||
remote_action={
|
||||
"status": "running",
|
||||
"message": "Выполняется обновление… (лог с хоста)",
|
||||
"output": "=== Script update completed successfully ===",
|
||||
"ok": True,
|
||||
"finished_at": "2026-07-08T02:21:11+00:00",
|
||||
},
|
||||
)
|
||||
db_session.add(host)
|
||||
db_session.commit()
|
||||
|
||||
status = get_remote_action_status(host)
|
||||
assert status["active"] is False
|
||||
assert status["agent_update_state"] == "success"
|
||||
|
||||
|
||||
def test_cancel_host_remote_job_api(jwt_headers, client, db_session):
|
||||
host = Host(
|
||||
hostname="cancel-me",
|
||||
os_family="windows",
|
||||
product="rdp-login-monitor",
|
||||
agent_update_state="running",
|
||||
remote_action={"status": "running", "title": "Обновление через WinRM"},
|
||||
)
|
||||
db_session.add(host)
|
||||
db_session.commit()
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1/hosts/{host.id}/actions/remote-job/cancel",
|
||||
headers=jwt_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["active"] is False
|
||||
assert body["status"] == "failed"
|
||||
@@ -1,6 +1,12 @@
|
||||
"""Agent version parse/compare for outdated highlighting."""
|
||||
|
||||
from app.services.agent_version import is_agent_version_outdated, latest_agent_versions_by_product, parse_agent_version
|
||||
from app.services.agent_version import (
|
||||
effective_reference_version,
|
||||
host_version_outdated,
|
||||
is_agent_version_outdated,
|
||||
latest_agent_versions_by_product,
|
||||
parse_agent_version,
|
||||
)
|
||||
from app.models import Host
|
||||
from datetime import datetime, timezone
|
||||
|
||||
@@ -27,6 +33,61 @@ def test_outdated_different_minor_or_major():
|
||||
assert is_agent_version_outdated("2.1.0-SAC", latest) is False
|
||||
|
||||
|
||||
def test_effective_reference_version_picks_max():
|
||||
assert (
|
||||
effective_reference_version(
|
||||
recommended="2.1.0-SAC",
|
||||
fleet_latest="2.0.6-SAC",
|
||||
min_version="",
|
||||
)
|
||||
== "2.1.0-SAC"
|
||||
)
|
||||
assert (
|
||||
effective_reference_version(
|
||||
recommended="",
|
||||
fleet_latest="2.0.6-SAC",
|
||||
min_version="2.1.0-SAC",
|
||||
)
|
||||
== "2.1.0-SAC"
|
||||
)
|
||||
assert (
|
||||
effective_reference_version(
|
||||
recommended="",
|
||||
fleet_latest="2.0.35-SAC",
|
||||
min_version="",
|
||||
)
|
||||
== "2.0.35-SAC"
|
||||
)
|
||||
|
||||
|
||||
def test_host_version_outdated_ssh_vs_git():
|
||||
assert (
|
||||
host_version_outdated(
|
||||
"2.0.6-SAC",
|
||||
recommended="2.1.0-SAC",
|
||||
fleet_latest="2.0.6-SAC",
|
||||
)
|
||||
is True
|
||||
)
|
||||
assert (
|
||||
host_version_outdated(
|
||||
"2.0.6-SAC",
|
||||
recommended="",
|
||||
fleet_latest="2.0.6-SAC",
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
host_version_outdated(
|
||||
"2.0.6-SAC",
|
||||
recommended="",
|
||||
fleet_latest="2.0.6-SAC",
|
||||
min_version="2.1.0-SAC",
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
def test_latest_agent_versions_by_product(db_session):
|
||||
now = datetime.now(timezone.utc)
|
||||
db_session.add_all(
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Tests for client IP resolution behind reverse proxy."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.services.client_ip import client_ip_from_request
|
||||
|
||||
|
||||
def _request(*, client_host: str = "10.0.0.5", xff: str | None = None):
|
||||
headers = {}
|
||||
if xff is not None:
|
||||
headers["x-forwarded-for"] = xff
|
||||
return SimpleNamespace(client=SimpleNamespace(host=client_host), headers=headers)
|
||||
|
||||
|
||||
def test_client_ip_without_forwarded_header():
|
||||
assert client_ip_from_request(_request(client_host="203.0.113.10")) == "203.0.113.10"
|
||||
|
||||
|
||||
def test_client_ip_uses_last_forwarded_hop():
|
||||
req = _request(client_host="127.0.0.1", xff="203.0.113.99, 198.51.100.20")
|
||||
assert client_ip_from_request(req) == "198.51.100.20"
|
||||
|
||||
|
||||
def test_client_ip_ignores_spoofed_first_hop():
|
||||
req = _request(client_host="127.0.0.1", xff="1.2.3.4, 203.0.113.50")
|
||||
assert client_ip_from_request(req) == "203.0.113.50"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user