Compare commits
39 Commits
91cde1668c
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 038363c5c7 | |||
| f7b3597206 | |||
| a746a82f2a | |||
| 6d5dfbbeed | |||
| 23fa83ddd6 | |||
| ac2384a479 | |||
| e21fae2ae5 | |||
| 6d52a34a37 | |||
| f033d5bd87 | |||
| f2bcc6c7df | |||
| 5708e42c66 | |||
| 8a937b8f17 | |||
| ccd50a085d | |||
| 55d58b25a3 | |||
| 4b8e3313ae | |||
| ab53d47329 | |||
| eecc0e1619 | |||
| 128dd278f6 | |||
| 8cefba1952 | |||
| b4dd593160 | |||
| f3a0adb7b8 | |||
| a0b473b4d7 | |||
| 2c1724fd90 | |||
| a9ca50bc15 | |||
| fb50fb3ea7 | |||
| 5d2b90779c | |||
| afa80d169f | |||
| 7f38c00094 | |||
| c12f169189 | |||
| 37fdeb44c5 | |||
| 746f9e23b2 | |||
| 35e25063e3 | |||
| 7466254401 | |||
| 8d17a0f560 | |||
| ae86cb3ea7 | |||
| c146bb119a | |||
| 1fcd5cb5cf | |||
| f48aee1d93 | |||
| d5db38e2ae |
@@ -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,13 +0,0 @@
|
|||||||
---
|
|
||||||
description: Code generation syntax restrictions to minimize output tokens
|
|
||||||
globs: "*.{ts,js,py,cs}"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Syntax Optimization
|
|
||||||
|
|
||||||
- **No JSDoc/Docstrings:** Do NOT write documentation, comments, JSDoc, or docstrings for generated functions unless explicitly asked.
|
|
||||||
- **No logs or prints:** Remove all `console.log`, `print()`, or debugging statements from the final code output.
|
|
||||||
- **Use concise syntax:**
|
|
||||||
- In JavaScript/TypeScript: Use arrow functions, optional chaining (`?.`), nullish coalescing (`??`), and destructuring.
|
|
||||||
- In Python: Use list comprehensions, dict comprehensions, and built-in functions.
|
|
||||||
- **Minimize imports:** Do not output the `import` statements section if the required packages are standard and already exist in the file.
|
|
||||||
@@ -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` (`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,22 +0,0 @@
|
|||||||
---
|
|
||||||
description: Global token-saving rules for ultra-concise communication and minimal context usage
|
|
||||||
globs: *
|
|
||||||
---
|
|
||||||
|
|
||||||
# Token-Saving Instructions
|
|
||||||
|
|
||||||
## Communication Strategy
|
|
||||||
- **Strictly no fluff:** Omit all greetings, pleasantries, apologies, and concluding remarks.
|
|
||||||
- **Direct answers only:** Start your response immediately with the solution, code block, or direct answer.
|
|
||||||
- **Extreme brevity:** Keep explanations under 2-3 sentences. Use bullet points instead of long paragraphs.
|
|
||||||
|
|
||||||
## Code Generation Guidelines
|
|
||||||
- **Do not restate existing code:** Never copy and paste parts of my existing file just to show where to insert changes.
|
|
||||||
- **Provide diffs only:** Show only the specific lines that need to be changed, added, or deleted. Use brief comments (`// ... existing code ...`) to show placement if necessary.
|
|
||||||
- **No boilerplate:** Do not generate setup code, imports, or boilerplate unless explicitly requested.
|
|
||||||
- **Single-line implementations:** Prefer concise, clean, short code syntax where readable (e.g., arrow functions, ternary operators).
|
|
||||||
|
|
||||||
## Code Review and Verification
|
|
||||||
- Do not explain why the code works unless asked.
|
|
||||||
- If the solution is simple, output *only* the code block and nothing else.
|
|
||||||
- If you need more information, ask a single, precise question. Do not list multiple hypotheticals.
|
|
||||||
@@ -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,8 @@
|
|||||||
|
.cursor/
|
||||||
|
tools/*.log
|
||||||
|
*.log
|
||||||
|
*.bak
|
||||||
|
Logs/
|
||||||
|
sac-spool/
|
||||||
|
login_monitor.settings.ps1
|
||||||
|
exchange_monitor.settings.ps1
|
||||||
+225
-25
@@ -260,11 +260,25 @@ function Copy-RdpMonitorDeployBundle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Test-RdpMonitorSettingsHasPlaceholderSecrets {
|
||||||
|
param([string]$SettingsPath)
|
||||||
|
$c = Get-RdpMonitorSettingsRaw -Path $SettingsPath
|
||||||
|
if ([string]::IsNullOrWhiteSpace($c)) { return $true }
|
||||||
|
|
||||||
|
if ($c -match '(?m)^\s*\$SacUrl\s*=\s*[''"]https?://[^''"]*example\.com[^''"]*[''"]') { return $true }
|
||||||
|
if ($c -match '(?m)^\s*\$SacApiKey\s*=\s*[''"]sac_CHANGE_ME[''"]') { return $true }
|
||||||
|
if ($c -match '(?m)^\s*\$TelegramBotToken\s*=\s*[''"]YOUR_BOT_TOKEN[''"]') { return $true }
|
||||||
|
if ($c -match '(?m)^\s*\$TelegramChatID\s*=\s*[''"]YOUR_CHAT_ID[''"]') { return $true }
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
function Test-RdpMonitorSettingsNeedsSacBootstrap {
|
function Test-RdpMonitorSettingsNeedsSacBootstrap {
|
||||||
param([string]$SettingsPath)
|
param([string]$SettingsPath)
|
||||||
$c = Get-RdpMonitorSettingsRaw -Path $SettingsPath
|
$c = Get-RdpMonitorSettingsRaw -Path $SettingsPath
|
||||||
if ([string]::IsNullOrWhiteSpace($c)) { return $true }
|
if ([string]::IsNullOrWhiteSpace($c)) { return $true }
|
||||||
|
|
||||||
|
if (Test-RdpMonitorSettingsHasPlaceholderSecrets -SettingsPath $SettingsPath) { return $true }
|
||||||
|
|
||||||
if ($c -notmatch '(?m)^\s*\$UseSAC\s*=') { return $true }
|
if ($c -notmatch '(?m)^\s*\$UseSAC\s*=') { return $true }
|
||||||
if ($c -match '(?m)^\s*\$UseSAC\s*=\s*[''"]off[''"]') {
|
if ($c -match '(?m)^\s*\$UseSAC\s*=\s*[''"]off[''"]') {
|
||||||
if ($c -notmatch '(?m)^\s*\$SacApiKey\s*=\s*[''"]sac_[^''"]+[''"]') { return $true }
|
if ($c -notmatch '(?m)^\s*\$SacApiKey\s*=\s*[''"]sac_[^''"]+[''"]') { return $true }
|
||||||
@@ -285,12 +299,24 @@ function Test-RdpMonitorSettingsNeedsServerDisplayNameHint {
|
|||||||
return $true
|
return $true
|
||||||
}
|
}
|
||||||
|
|
||||||
function Test-RdpMonitorSettingsNeedsDailyReportHint {
|
function Test-RdpMonitorSettingsDailyReportEnabledIsTrue {
|
||||||
param([string]$SettingsPath)
|
param([string]$SettingsPath)
|
||||||
if (-not (Test-Path -LiteralPath $SettingsPath)) { return $false }
|
if (-not (Test-Path -LiteralPath $SettingsPath)) { return $false }
|
||||||
$c = Get-RdpMonitorSettingsRaw -Path $SettingsPath
|
$c = Get-RdpMonitorSettingsRaw -Path $SettingsPath
|
||||||
if ([string]::IsNullOrWhiteSpace($c)) { return $false }
|
if ([string]::IsNullOrWhiteSpace($c)) { return $false }
|
||||||
if ($c -match '(?m)^\s*(\#\s*)?\$DailyReportEnabled\s*=') { return $false }
|
if ($c -match '(?m)^\s*\$DailyReportEnabled\s*=\s*\$(?:true)\b') { return $true }
|
||||||
|
if ($c -match '(?m)^\s*\$DailyReportEnabled\s*=\s*1\s*(?:#.*)?$') { return $true }
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-RdpMonitorSettingsNeedsDailyReportHint {
|
||||||
|
param([string]$SettingsPath)
|
||||||
|
if (-not (Test-Path -LiteralPath $SettingsPath)) { return $true }
|
||||||
|
$c = Get-RdpMonitorSettingsRaw -Path $SettingsPath
|
||||||
|
if ([string]::IsNullOrWhiteSpace($c)) { return $true }
|
||||||
|
if (Test-RdpMonitorSettingsDailyReportEnabledIsTrue -SettingsPath $SettingsPath) { return $false }
|
||||||
|
if (Test-RdpMonitorSettingsHasInvalidDailyReportAssignment -SettingsPath $SettingsPath) { return $true }
|
||||||
|
if ($c -match '(?m)^\s*(\#\s*)?\$DailyReportEnabled\s*=') { return $true }
|
||||||
return $true
|
return $true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,7 +353,7 @@ function Repair-RdpMonitorSettingsDailyReportAssignmentIfInvalid {
|
|||||||
return $true
|
return $true
|
||||||
}
|
}
|
||||||
|
|
||||||
function Update-RdpMonitorSettingsDailyReportHintIfMissing {
|
function Sync-RdpMonitorSettingsDailyReportEnabledToTrue {
|
||||||
param([string]$LocalSettings)
|
param([string]$LocalSettings)
|
||||||
if (-not (Test-Path -LiteralPath $LocalSettings)) { return $false }
|
if (-not (Test-Path -LiteralPath $LocalSettings)) { return $false }
|
||||||
if (-not (Test-RdpMonitorSettingsNeedsDailyReportHint -SettingsPath $LocalSettings)) {
|
if (-not (Test-RdpMonitorSettingsNeedsDailyReportHint -SettingsPath $LocalSettings)) {
|
||||||
@@ -337,24 +363,35 @@ function Update-RdpMonitorSettingsDailyReportHintIfMissing {
|
|||||||
$c = Get-RdpMonitorSettingsRaw -Path $LocalSettings
|
$c = Get-RdpMonitorSettingsRaw -Path $LocalSettings
|
||||||
if ([string]::IsNullOrWhiteSpace($c)) { return $false }
|
if ([string]::IsNullOrWhiteSpace($c)) { return $false }
|
||||||
|
|
||||||
|
$dailyLine = '$DailyReportEnabled = $true # отчёт с агента (report.daily.rdp); $false или 0 — только SAC'
|
||||||
$hintBlock = @(
|
$hintBlock = @(
|
||||||
'# --- Суточный отчёт report.daily.rdp (агент или только SAC) ---'
|
'# --- Суточный отчёт report.daily.rdp (агент или только SAC) ---'
|
||||||
'# $DailyReportEnabled = $false # по умолчанию: только SAC; $true или 1 — отчёт с агента'
|
$dailyLine
|
||||||
'# Не пишите "= false" без $ — PowerShell воспримет false как команду.'
|
'# Не пишите "= false" без $ — PowerShell воспримет false как команду.'
|
||||||
) -join "`r`n"
|
) -join "`r`n"
|
||||||
|
|
||||||
$bak = "$LocalSettings.bak.$(Get-Date -Format 'yyyyMMdd_HHmmss')"
|
$bak = "$LocalSettings.bak.$(Get-Date -Format 'yyyyMMdd_HHmmss')"
|
||||||
Copy-Item -LiteralPath $LocalSettings -Destination $bak -Force
|
Copy-Item -LiteralPath $LocalSettings -Destination $bak -Force
|
||||||
Write-DeployLog "Добавление закомментированного `$DailyReportEnabled в login_monitor.settings.ps1; резервная копия: $bak"
|
|
||||||
|
|
||||||
$insertBefore = '(?m)^\s*#\s*---\s*Security Alert Center'
|
if ($c -match '(?m)^\s*(\#\s*)?\$DailyReportEnabled\s*=') {
|
||||||
if ($c -match $insertBefore) {
|
Write-DeployLog "login_monitor.settings.ps1: `$DailyReportEnabled → `$true (резервная копия: $bak)"
|
||||||
$newContent = [regex]::Replace($c, $insertBefore, ($hintBlock + "`r`n`r`n" + '$0'), 1)
|
$newContent = [regex]::Replace(
|
||||||
|
$c,
|
||||||
|
'(?m)^\s*(\#\s*)?\$DailyReportEnabled\s*=.*$',
|
||||||
|
$dailyLine,
|
||||||
|
1
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
$newContent = ($c.TrimEnd() + "`r`n`r`n" + $hintBlock + "`r`n")
|
Write-DeployLog "login_monitor.settings.ps1: добавлен `$DailyReportEnabled = `$true (резервная копия: $bak)"
|
||||||
|
$insertBefore = '(?m)^\s*#\s*---\s*Security Alert Center'
|
||||||
|
if ($c -match $insertBefore) {
|
||||||
|
$newContent = [regex]::Replace($c, $insertBefore, ($hintBlock + "`r`n`r`n" + '$0'), 1)
|
||||||
|
} else {
|
||||||
|
$newContent = ($c.TrimEnd() + "`r`n`r`n" + $hintBlock + "`r`n")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[System.IO.File]::WriteAllText($LocalSettings, $newContent.TrimEnd() + "`r`n", $Utf8Bom)
|
[System.IO.File]::WriteAllText($LocalSettings, $newContent.TrimEnd() + "`r`n", $Utf8Bom)
|
||||||
Write-DeployLog 'login_monitor.settings.ps1: добавлена подсказка # $DailyReportEnabled = $false'
|
|
||||||
return $true
|
return $true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,7 +402,7 @@ function Sync-RdpMonitorSettingsDailyReportPatches {
|
|||||||
if (Repair-RdpMonitorSettingsDailyReportAssignmentIfInvalid -LocalSettings $LocalSettings) {
|
if (Repair-RdpMonitorSettingsDailyReportAssignmentIfInvalid -LocalSettings $LocalSettings) {
|
||||||
$changed = $true
|
$changed = $true
|
||||||
}
|
}
|
||||||
if (Update-RdpMonitorSettingsDailyReportHintIfMissing -LocalSettings $LocalSettings) {
|
if (Sync-RdpMonitorSettingsDailyReportEnabledToTrue -LocalSettings $LocalSettings) {
|
||||||
$changed = $true
|
$changed = $true
|
||||||
}
|
}
|
||||||
return $changed
|
return $changed
|
||||||
@@ -632,6 +669,14 @@ function Ensure-RdpMonitorWinRmOperationalLogEnabled {
|
|||||||
return $false
|
return $false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Test-RdpMonitorSettingsNeedsHeartbeatInterval {
|
||||||
|
param([string]$SettingsPath)
|
||||||
|
if (-not (Test-Path -LiteralPath $SettingsPath)) { return $true }
|
||||||
|
$c = Get-RdpMonitorSettingsRaw -Path $SettingsPath
|
||||||
|
if ([string]::IsNullOrWhiteSpace($c)) { return $true }
|
||||||
|
return ($c -notmatch '(?m)^\s*\$HeartbeatInterval\s*=\s*\d+')
|
||||||
|
}
|
||||||
|
|
||||||
function Sync-RdpMonitorSettingsHeartbeatInterval {
|
function Sync-RdpMonitorSettingsHeartbeatInterval {
|
||||||
param(
|
param(
|
||||||
[string]$LocalSettings,
|
[string]$LocalSettings,
|
||||||
@@ -674,17 +719,106 @@ function Sync-RdpMonitorSettingsHeartbeatInterval {
|
|||||||
return $true
|
return $true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Test-RdpMonitorSettingsNeedsHeartbeatStaleAlertMultiplier {
|
||||||
|
param([string]$SettingsPath)
|
||||||
|
if (-not (Test-Path -LiteralPath $SettingsPath)) { return $true }
|
||||||
|
$c = Get-RdpMonitorSettingsRaw -Path $SettingsPath
|
||||||
|
if ([string]::IsNullOrWhiteSpace($c)) { return $true }
|
||||||
|
return ($c -notmatch '(?m)^\s*\$HeartbeatStaleAlertMultiplier\s*=\s*\d+')
|
||||||
|
}
|
||||||
|
|
||||||
|
function Sync-RdpMonitorSettingsHeartbeatStaleAlertMultiplierIfMissing {
|
||||||
|
param(
|
||||||
|
[string]$LocalSettings,
|
||||||
|
[int]$TargetMultiplier = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
if (-not (Test-RdpMonitorSettingsNeedsHeartbeatStaleAlertMultiplier -SettingsPath $LocalSettings)) {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $LocalSettings)) { return $false }
|
||||||
|
|
||||||
|
$c = Get-RdpMonitorSettingsRaw -Path $LocalSettings
|
||||||
|
$block = @(
|
||||||
|
'# --- Heartbeat stale alert: множитель × HeartbeatInterval ---'
|
||||||
|
"`$HeartbeatStaleAlertMultiplier = $TargetMultiplier"
|
||||||
|
) -join "`r`n"
|
||||||
|
|
||||||
|
$bak = "$LocalSettings.bak.$(Get-Date -Format 'yyyyMMdd_HHmmss')"
|
||||||
|
Copy-Item -LiteralPath $LocalSettings -Destination $bak -Force
|
||||||
|
|
||||||
|
$insertAfterHeartbeat = '(?m)^(\s*\$HeartbeatInterval\s*=\s*\d+\s*)$'
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($c) -and $c -match $insertAfterHeartbeat) {
|
||||||
|
$newContent = [regex]::Replace($c, $insertAfterHeartbeat, ('$1' + "`r`n" + $block), 1)
|
||||||
|
} elseif (-not [string]::IsNullOrWhiteSpace($c)) {
|
||||||
|
$newContent = ($c.TrimEnd() + "`r`n`r`n" + $block + "`r`n")
|
||||||
|
} else {
|
||||||
|
$newContent = ($block + "`r`n")
|
||||||
|
}
|
||||||
|
|
||||||
|
[System.IO.File]::WriteAllText($LocalSettings, $newContent.TrimEnd() + "`r`n", $Utf8Bom)
|
||||||
|
Write-DeployLog "login_monitor.settings.ps1: добавлен `$HeartbeatStaleAlertMultiplier = $TargetMultiplier (резервная копия: $bak)"
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-RdpMonitorSettingsNeedsStartupRebootDetectMinutes {
|
||||||
|
param([string]$SettingsPath)
|
||||||
|
if (-not (Test-Path -LiteralPath $SettingsPath)) { return $true }
|
||||||
|
$c = Get-RdpMonitorSettingsRaw -Path $SettingsPath
|
||||||
|
if ([string]::IsNullOrWhiteSpace($c)) { return $true }
|
||||||
|
return ($c -notmatch '(?m)^\s*\$StartupRebootDetectMinutes\s*=\s*\d+')
|
||||||
|
}
|
||||||
|
|
||||||
|
function Sync-RdpMonitorSettingsStartupRebootDetectMinutesIfMissing {
|
||||||
|
param(
|
||||||
|
[string]$LocalSettings,
|
||||||
|
[int]$TargetMinutes = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
if (-not (Test-RdpMonitorSettingsNeedsStartupRebootDetectMinutes -SettingsPath $LocalSettings)) {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $LocalSettings)) { return $false }
|
||||||
|
|
||||||
|
$c = Get-RdpMonitorSettingsRaw -Path $LocalSettings
|
||||||
|
$block = @(
|
||||||
|
'# --- Окно (мин): LastBootUpTime + System 41/1074/6005/6008/6009 → os_reboot при старте ---'
|
||||||
|
"`$StartupRebootDetectMinutes = $TargetMinutes"
|
||||||
|
) -join "`r`n"
|
||||||
|
|
||||||
|
$bak = "$LocalSettings.bak.$(Get-Date -Format 'yyyyMMdd_HHmmss')"
|
||||||
|
Copy-Item -LiteralPath $LocalSettings -Destination $bak -Force
|
||||||
|
|
||||||
|
$insertAfterHeartbeat = '(?m)^(\s*\$HeartbeatInterval\s*=\s*\d+\s*)$'
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($c) -and $c -match $insertAfterHeartbeat) {
|
||||||
|
$newContent = [regex]::Replace($c, $insertAfterHeartbeat, ('$1' + "`r`n`r`n" + $block), 1)
|
||||||
|
} elseif (-not [string]::IsNullOrWhiteSpace($c)) {
|
||||||
|
$newContent = ($c.TrimEnd() + "`r`n`r`n" + $block + "`r`n")
|
||||||
|
} else {
|
||||||
|
$newContent = ($block + "`r`n")
|
||||||
|
}
|
||||||
|
|
||||||
|
[System.IO.File]::WriteAllText($LocalSettings, $newContent.TrimEnd() + "`r`n", $Utf8Bom)
|
||||||
|
Write-DeployLog "login_monitor.settings.ps1: добавлен `$StartupRebootDetectMinutes = $TargetMinutes (резервная копия: $bak)"
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
function Invoke-RdpMonitorSettingsPostPatches {
|
function Invoke-RdpMonitorSettingsPostPatches {
|
||||||
param([string]$LocalSettings)
|
param([string]$LocalSettings)
|
||||||
if (-not (Test-Path -LiteralPath $LocalSettings)) { return $false }
|
if (-not (Test-Path -LiteralPath $LocalSettings)) { return $false }
|
||||||
$changed = $false
|
$changed = $false
|
||||||
if (Update-RdpMonitorSettingsServerDisplayNameHintIfMissing -LocalSettings $LocalSettings) { $changed = $true }
|
if (Update-RdpMonitorSettingsServerDisplayNameHintIfMissing -LocalSettings $LocalSettings) { $changed = $true }
|
||||||
|
if (Update-RdpMonitorSettingsServerIPv4HintIfMissing -LocalSettings $LocalSettings) { $changed = $true }
|
||||||
if (Sync-RdpMonitorSettingsDailyReportPatches -LocalSettings $LocalSettings) { $changed = $true }
|
if (Sync-RdpMonitorSettingsDailyReportPatches -LocalSettings $LocalSettings) { $changed = $true }
|
||||||
if (Sync-RdpMonitorSettingsExchangeNoisePatches -LocalSettings $LocalSettings) { $changed = $true }
|
if (Sync-RdpMonitorSettingsExchangeNoisePatches -LocalSettings $LocalSettings) { $changed = $true }
|
||||||
if (Repair-RdpMonitorSettingsWinRmLinesIfInvalid -LocalSettings $LocalSettings) { $changed = $true }
|
if (Repair-RdpMonitorSettingsWinRmLinesIfInvalid -LocalSettings $LocalSettings) { $changed = $true }
|
||||||
if (Sync-RdpMonitorSettingsWinRmInboundBlock -LocalSettings $LocalSettings) { $changed = $true }
|
if (Sync-RdpMonitorSettingsWinRmInboundBlock -LocalSettings $LocalSettings) { $changed = $true }
|
||||||
if (Sync-RdpMonitorSettingsMaxBackupDaysIfMissing -LocalSettings $LocalSettings) { $changed = $true }
|
if (Sync-RdpMonitorSettingsMaxBackupDaysIfMissing -LocalSettings $LocalSettings) { $changed = $true }
|
||||||
if (Sync-RdpMonitorSettingsGetInventoryIfMissing -LocalSettings $LocalSettings) { $changed = $true }
|
if (Sync-RdpMonitorSettingsGetInventoryIfMissing -LocalSettings $LocalSettings) { $changed = $true }
|
||||||
|
if (Sync-RdpMonitorSettingsHeartbeatStaleAlertMultiplierIfMissing -LocalSettings $LocalSettings) { $changed = $true }
|
||||||
|
if (Sync-RdpMonitorSettingsStartupRebootDetectMinutesIfMissing -LocalSettings $LocalSettings) { $changed = $true }
|
||||||
return $changed
|
return $changed
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -790,8 +924,6 @@ function Update-RdpMonitorSettingsServerDisplayNameHintIfMissing {
|
|||||||
$hintBlock = @(
|
$hintBlock = @(
|
||||||
'# --- Подпись сервера в Telegram и SAC (host.display_name); раскомментируйте при необходимости ---'
|
'# --- Подпись сервера в Telegram и SAC (host.display_name); раскомментируйте при необходимости ---'
|
||||||
"# `$ServerDisplayName = '$hostLabel'"
|
"# `$ServerDisplayName = '$hostLabel'"
|
||||||
'# --- Явный IPv4 хоста для SAC (опционально; иначе автоопределение) ---'
|
|
||||||
'# $ServerIPv4 = '''
|
|
||||||
) -join "`r`n"
|
) -join "`r`n"
|
||||||
|
|
||||||
$bak = "$LocalSettings.bak.$(Get-Date -Format 'yyyyMMdd_HHmmss')"
|
$bak = "$LocalSettings.bak.$(Get-Date -Format 'yyyyMMdd_HHmmss')"
|
||||||
@@ -809,6 +941,51 @@ function Update-RdpMonitorSettingsServerDisplayNameHintIfMissing {
|
|||||||
return $true
|
return $true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Test-RdpMonitorSettingsNeedsServerIPv4Hint {
|
||||||
|
param([string]$SettingsPath)
|
||||||
|
if (-not (Test-Path -LiteralPath $SettingsPath)) { return $false }
|
||||||
|
$c = Get-RdpMonitorSettingsRaw -Path $SettingsPath
|
||||||
|
if ([string]::IsNullOrWhiteSpace($c)) { return $false }
|
||||||
|
if ($c -match '(?m)^\s*(\#\s*)?\$ServerIPv4\s*=') { return $false }
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
function Update-RdpMonitorSettingsServerIPv4HintIfMissing {
|
||||||
|
param([string]$LocalSettings)
|
||||||
|
if (-not (Test-Path -LiteralPath $LocalSettings)) { return $false }
|
||||||
|
if (-not (Test-RdpMonitorSettingsNeedsServerIPv4Hint -SettingsPath $LocalSettings)) {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
$c = Get-RdpMonitorSettingsRaw -Path $LocalSettings
|
||||||
|
if ([string]::IsNullOrWhiteSpace($c)) { return $false }
|
||||||
|
|
||||||
|
$hintBlock = @(
|
||||||
|
'# --- Явный IPv4 хоста для SAC (опционально; иначе автоопределение) ---'
|
||||||
|
"# `$ServerIPv4 = '10.0.0.10'"
|
||||||
|
) -join "`r`n"
|
||||||
|
|
||||||
|
$bak = "$LocalSettings.bak.$(Get-Date -Format 'yyyyMMdd_HHmmss')"
|
||||||
|
Copy-Item -LiteralPath $LocalSettings -Destination $bak -Force
|
||||||
|
Write-DeployLog "Добавление закомментированного `$ServerIPv4 в login_monitor.settings.ps1; резервная копия: $bak"
|
||||||
|
|
||||||
|
$insertAfterDisplay = '(?m)^(\s*(\#\s*)?\$ServerDisplayName\s*=.*)$'
|
||||||
|
if ($c -match $insertAfterDisplay) {
|
||||||
|
$newContent = [regex]::Replace($c, $insertAfterDisplay, ('$1' + "`r`n" + $hintBlock), 1)
|
||||||
|
} else {
|
||||||
|
$insertBefore = '(?m)^\s*#\s*---\s*Security Alert Center'
|
||||||
|
if ($c -match $insertBefore) {
|
||||||
|
$newContent = [regex]::Replace($c, $insertBefore, ($hintBlock + "`r`n`r`n" + '$0'), 1)
|
||||||
|
} else {
|
||||||
|
$newContent = ($c.TrimEnd() + "`r`n`r`n" + $hintBlock + "`r`n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[System.IO.File]::WriteAllText($LocalSettings, $newContent.TrimEnd() + "`r`n", $Utf8Bom)
|
||||||
|
Write-DeployLog "login_monitor.settings.ps1: добавлена подсказка # `$ServerIPv4 = '10.0.0.10'"
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
function Get-RdpMonitorSacBlockFromExample {
|
function Get-RdpMonitorSacBlockFromExample {
|
||||||
param([string]$ExamplePath)
|
param([string]$ExamplePath)
|
||||||
if (-not (Test-Path -LiteralPath $ExamplePath)) { return $null }
|
if (-not (Test-Path -LiteralPath $ExamplePath)) { return $null }
|
||||||
@@ -817,12 +994,8 @@ function Get-RdpMonitorSacBlockFromExample {
|
|||||||
if ($ex -match '(?ms)(#\s*---\s*Security Alert Center.*?)(?=\r?\n#\s*---|\z)') {
|
if ($ex -match '(?ms)(#\s*---\s*Security Alert Center.*?)(?=\r?\n#\s*---|\z)') {
|
||||||
return $Matches[1].TrimEnd()
|
return $Matches[1].TrimEnd()
|
||||||
}
|
}
|
||||||
return @(
|
Write-DeployLog "Предупреждение: в example нет блока Security Alert Center — patch SAC пропущен."
|
||||||
'# --- Security Alert Center (SAC) ---'
|
return $null
|
||||||
'$UseSAC = ''fallback'''
|
|
||||||
'$SacUrl = ''https://sac.kalinamall.ru'''
|
|
||||||
'$SacApiKey = ''sac_CHANGE_ME'''
|
|
||||||
) -join "`r`n"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function Sync-RdpMonitorUseSacFallbackMode {
|
function Sync-RdpMonitorUseSacFallbackMode {
|
||||||
@@ -860,8 +1033,10 @@ function Update-RdpMonitorSettingsSacBlockIfMissing {
|
|||||||
|
|
||||||
if ($c -match '(?m)^\s*\$UseSAC\s*=' -and $c -match '(?m)^\s*\$SacApiKey\s*=\s*[''"]sac_[^''"]+[''"]') {
|
if ($c -match '(?m)^\s*\$UseSAC\s*=' -and $c -match '(?m)^\s*\$SacApiKey\s*=\s*[''"]sac_[^''"]+[''"]') {
|
||||||
if ($c -notmatch '(?m)^\s*\$UseSAC\s*=\s*[''"]off[''"]') {
|
if ($c -notmatch '(?m)^\s*\$UseSAC\s*=\s*[''"]off[''"]') {
|
||||||
Write-DeployLog "login_monitor.settings.ps1: блок SAC уже задан, файл не меняем."
|
if (-not (Test-RdpMonitorSettingsHasPlaceholderSecrets -SettingsPath $LocalSettings)) {
|
||||||
return $false
|
Write-DeployLog "login_monitor.settings.ps1: блок SAC уже задан, файл не меняем."
|
||||||
|
return $false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1333,13 +1508,16 @@ try {
|
|||||||
|
|
||||||
$needsSettingsBootstrap = Test-RdpMonitorSettingsNeedsSacBootstrap -SettingsPath $settingsLocal
|
$needsSettingsBootstrap = Test-RdpMonitorSettingsNeedsSacBootstrap -SettingsPath $settingsLocal
|
||||||
$needsDisplayNameHint = Test-RdpMonitorSettingsNeedsServerDisplayNameHint -SettingsPath $settingsLocal
|
$needsDisplayNameHint = Test-RdpMonitorSettingsNeedsServerDisplayNameHint -SettingsPath $settingsLocal
|
||||||
|
$needsServerIPv4Hint = Test-RdpMonitorSettingsNeedsServerIPv4Hint -SettingsPath $settingsLocal
|
||||||
$needsDailyReportHint = Test-RdpMonitorSettingsNeedsDailyReportHint -SettingsPath $settingsLocal
|
$needsDailyReportHint = Test-RdpMonitorSettingsNeedsDailyReportHint -SettingsPath $settingsLocal
|
||||||
$needsDailyReportRepair = Test-RdpMonitorSettingsHasInvalidDailyReportAssignment -SettingsPath $settingsLocal
|
$needsDailyReportRepair = Test-RdpMonitorSettingsHasInvalidDailyReportAssignment -SettingsPath $settingsLocal
|
||||||
$needsExchangeNoisePatch = Test-RdpMonitorSettingsNeedsExchangeNoisePatch -SettingsPath $settingsLocal
|
$needsExchangeNoisePatch = Test-RdpMonitorSettingsNeedsExchangeNoisePatch -SettingsPath $settingsLocal
|
||||||
$needsWinRmInboundBlock = Test-RdpMonitorSettingsNeedsWinRmInboundBlock -SettingsPath $settingsLocal
|
$needsWinRmInboundBlock = Test-RdpMonitorSettingsNeedsWinRmInboundBlock -SettingsPath $settingsLocal
|
||||||
|
$needsHeartbeatInterval = Test-RdpMonitorSettingsNeedsHeartbeatInterval -SettingsPath $settingsLocal
|
||||||
|
$needsStartupRebootDetectMinutes = Test-RdpMonitorSettingsNeedsStartupRebootDetectMinutes -SettingsPath $settingsLocal
|
||||||
$needsBundleSync = Test-RdpMonitorDeployBundleNeedsSync -ShareRoot $shareRoot
|
$needsBundleSync = Test-RdpMonitorDeployBundleNeedsSync -ShareRoot $shareRoot
|
||||||
$needsTaskExecutionLimitFix = Test-RdpMonitorDeployMainTaskNeedsUnlimitedExecutionTime -ShareRoot $shareRoot
|
$needsTaskExecutionLimitFix = Test-RdpMonitorDeployMainTaskNeedsUnlimitedExecutionTime -ShareRoot $shareRoot
|
||||||
$needsSacBootstrap = $needsSettingsBootstrap -or $needsBundleSync -or $needsDisplayNameHint -or $needsDailyReportHint -or $needsDailyReportRepair -or $needsExchangeNoisePatch -or $needsWinRmInboundBlock -or $needsTaskExecutionLimitFix
|
$needsSacBootstrap = $needsSettingsBootstrap -or $needsBundleSync -or $needsDisplayNameHint -or $needsServerIPv4Hint -or $needsDailyReportHint -or $needsDailyReportRepair -or $needsExchangeNoisePatch -or $needsWinRmInboundBlock -or $needsHeartbeatInterval -or $needsStartupRebootDetectMinutes -or $needsTaskExecutionLimitFix
|
||||||
|
|
||||||
if (Test-RdpMonitorExchangeServerRole) {
|
if (Test-RdpMonitorExchangeServerRole) {
|
||||||
Write-DeployLog "Обнаружена роль Exchange — при необходимости допишем WinRM/4624 noise settings в login_monitor.settings.ps1."
|
Write-DeployLog "Обнаружена роль Exchange — при необходимости допишем WinRM/4624 noise settings в login_monitor.settings.ps1."
|
||||||
@@ -1368,8 +1546,10 @@ try {
|
|||||||
Write-DeployLog "Версия совпадает ($shareVerRaw), но нужна донастройка SAC в settings — продолжаем деплой."
|
Write-DeployLog "Версия совпадает ($shareVerRaw), но нужна донастройка SAC в settings — продолжаем деплой."
|
||||||
} elseif ($needsDisplayNameHint) {
|
} elseif ($needsDisplayNameHint) {
|
||||||
Write-DeployLog "Версия совпадает ($shareVerRaw), но нет подсказки `$ServerDisplayName в settings — продолжаем деплой."
|
Write-DeployLog "Версия совпадает ($shareVerRaw), но нет подсказки `$ServerDisplayName в settings — продолжаем деплой."
|
||||||
|
} elseif ($needsServerIPv4Hint) {
|
||||||
|
Write-DeployLog "Версия совпадает ($shareVerRaw), но нет подсказки `$ServerIPv4 в settings — продолжаем деплой."
|
||||||
} elseif ($needsDailyReportHint) {
|
} elseif ($needsDailyReportHint) {
|
||||||
Write-DeployLog "Версия совпадает ($shareVerRaw), но нет `$DailyReportEnabled в settings — продолжаем деплой."
|
Write-DeployLog "Версия совпадает ($shareVerRaw), но `$DailyReportEnabled не `$true — допишем/исправим и продолжим деплой."
|
||||||
} elseif ($needsDailyReportRepair) {
|
} elseif ($needsDailyReportRepair) {
|
||||||
Write-DeployLog "Версия совпадает ($shareVerRaw), но в settings некорректно задан `$DailyReportEnabled (= true/false без `$) — продолжаем деплой."
|
Write-DeployLog "Версия совпадает ($shareVerRaw), но в settings некорректно задан `$DailyReportEnabled (= true/false без `$) — продолжаем деплой."
|
||||||
} elseif ($needsExchangeNoisePatch) {
|
} elseif ($needsExchangeNoisePatch) {
|
||||||
@@ -1379,6 +1559,10 @@ try {
|
|||||||
} elseif ($needsTaskExecutionLimitFix) {
|
} elseif ($needsTaskExecutionLimitFix) {
|
||||||
$limitLabel = Get-RdpMonitorDeployMainTaskExecutionTimeLimitLabel -ShareRoot $shareRoot
|
$limitLabel = Get-RdpMonitorDeployMainTaskExecutionTimeLimitLabel -ShareRoot $shareRoot
|
||||||
Write-DeployLog "Версия совпадает ($shareVerRaw), но RDP-Login-Monitor имеет ExecutionTimeLimit=$limitLabel — перерегистрируем задачи (InstallTasks)."
|
Write-DeployLog "Версия совпадает ($shareVerRaw), но RDP-Login-Monitor имеет ExecutionTimeLimit=$limitLabel — перерегистрируем задачи (InstallTasks)."
|
||||||
|
} elseif ($needsHeartbeatInterval) {
|
||||||
|
Write-DeployLog "Версия совпадает ($shareVerRaw), но в settings нет `$HeartbeatInterval — допишем 14400 с (4 ч) и продолжим деплой."
|
||||||
|
} elseif ($needsStartupRebootDetectMinutes) {
|
||||||
|
Write-DeployLog "Версия совпадает ($shareVerRaw), но в settings нет `$StartupRebootDetectMinutes — допишем 5 мин и продолжим деплой."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($cmp -lt 0 -and -not $AllowDowngrade) {
|
if ($cmp -lt 0 -and -not $AllowDowngrade) {
|
||||||
@@ -1417,8 +1601,24 @@ try {
|
|||||||
|
|
||||||
Sync-RdpMonitorSettingsFromShare -ExampleOnShare $settingsExampleShare -LocalSettings $settingsLocal
|
Sync-RdpMonitorSettingsFromShare -ExampleOnShare $settingsExampleShare -LocalSettings $settingsLocal
|
||||||
|
|
||||||
# При каждом deploy выравниваем HeartbeatInterval в settings (14400 с = 4 ч), не только при bump версии.
|
# При каждом deploy проверяем HeartbeatInterval в settings (14400 с = 4 ч); если нет — дописываем.
|
||||||
Sync-RdpMonitorSettingsHeartbeatInterval -LocalSettings $settingsLocal | Out-Null
|
$heartbeatIntervalChanged = Sync-RdpMonitorSettingsHeartbeatInterval -LocalSettings $settingsLocal
|
||||||
|
if ($heartbeatIntervalChanged) {
|
||||||
|
$canonicalHb = [System.IO.Path]::GetFullPath($LocalScript)
|
||||||
|
if (Test-RdpMonitorMainProcessRunning -CanonicalScript $canonicalHb) {
|
||||||
|
Set-RdpMonitorRestartRequestFromDeploy -Mode 'settings' -Reason 'heartbeat_interval_deploy'
|
||||||
|
Write-DeployLog "HeartbeatInterval изменён — запрошена перезагрузка settings у работающего монитора."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$startupRebootDetectChanged = Sync-RdpMonitorSettingsStartupRebootDetectMinutesIfMissing -LocalSettings $settingsLocal
|
||||||
|
if ($startupRebootDetectChanged) {
|
||||||
|
$canonicalSr = [System.IO.Path]::GetFullPath($LocalScript)
|
||||||
|
if (Test-RdpMonitorMainProcessRunning -CanonicalScript $canonicalSr) {
|
||||||
|
Set-RdpMonitorRestartRequestFromDeploy -Mode 'settings' -Reason 'startup_reboot_detect_deploy'
|
||||||
|
Write-DeployLog "StartupRebootDetectMinutes добавлен — запрошена перезагрузка settings у работающего монитора."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ($isScriptVersionUpgrade) {
|
if ($isScriptVersionUpgrade) {
|
||||||
Sync-RdpMonitorUseSacFallbackMode -LocalSettings $settingsLocal | Out-Null
|
Sync-RdpMonitorUseSacFallbackMode -LocalSettings $settingsLocal | Out-Null
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<#
|
<#
|
||||||
.SYNOPSIS
|
.SYNOPSIS
|
||||||
Диагностика RDP Login Monitor после входа по RDP (или при «тишине» в Telegram/SAC).
|
Диагностика RDP Login Monitor после входа по RDP (или при «тишине» в Telegram/SAC).
|
||||||
.DESCRIPTION
|
.DESCRIPTION
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
Security 4624/4625/4778/4634, симуляцию фильтров монитора, SAC spool, сессии RDP.
|
Security 4624/4625/4778/4634, симуляцию фильтров монитора, SAC spool, сессии RDP.
|
||||||
Отчёт: C:\ProgramData\RDP-login-monitor\Logs\diagnose_YYYYMMDD_HHmmss.txt
|
Отчёт: C:\ProgramData\RDP-login-monitor\Logs\diagnose_YYYYMMDD_HHmmss.txt
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File Diagnose-RdpLoginMonitor.ps1 -MinutesBack 15 -ExpectedUser papatramp
|
powershell.exe -NoProfile -ExecutionPolicy Bypass -File Diagnose-RdpLoginMonitor.ps1 -MinutesBack 15 -ExpectedUser jdoe
|
||||||
.NOTES
|
.NOTES
|
||||||
Рекомендуется запуск от администратора. Без прав Security-журнал может быть неполным.
|
Рекомендуется запуск от администратора. Без прав Security-журнал может быть неполным.
|
||||||
#>
|
#>
|
||||||
@@ -300,6 +300,7 @@ if (-not $isAdmin) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$since = (Get-Date).AddMinutes(-1 * [math]::Abs($MinutesBack))
|
$since = (Get-Date).AddMinutes(-1 * [math]::Abs($MinutesBack))
|
||||||
|
$RcmLogName = 'Microsoft-Windows-TerminalServices-RemoteConnectionManager/Operational'
|
||||||
$isWs = Test-IsWorkstationOs
|
$isWs = Test-IsWorkstationOs
|
||||||
[void]$report.AppendLine("OS ProductType workstation=$isWs (server mode LT 2/3/10, workstation LT 10)")
|
[void]$report.AppendLine("OS ProductType workstation=$isWs (server mode LT 2/3/10, workstation LT 10)")
|
||||||
[void]$report.AppendLine("Window StartTime >= $($since.ToString('yyyy-MM-dd HH:mm:ss'))")
|
[void]$report.AppendLine("Window StartTime >= $($since.ToString('yyyy-MM-dd HH:mm:ss'))")
|
||||||
@@ -399,8 +400,42 @@ if ($notifyable.Count -eq 0 -and $recent4624.Count -gt 0) {
|
|||||||
|
|
||||||
[void]$report.AppendLine('')
|
[void]$report.AppendLine('')
|
||||||
[void]$report.AppendLine('UseSAC=exclusive: Telegram по rdp.login.success только из SAC (не локально агентом).')
|
[void]$report.AppendLine('UseSAC=exclusive: Telegram по rdp.login.success только из SAC (не локально агентом).')
|
||||||
|
[void]$report.AppendLine('На сервере RDS без аудита Security 4624 — смотрите RCM Operational 1149 (2.1.5-SAC+: исправлен silent skip при ComputerName=-).')
|
||||||
[void]$report.AppendLine('rdp.login.success = severity info; при SAC min_severity=warning Telegram не уйдёт, но событие в UI SAC должно быть.')
|
[void]$report.AppendLine('rdp.login.success = severity info; при SAC min_severity=warning Telegram не уйдёт, но событие в UI SAC должно быть.')
|
||||||
[void]$report.AppendLine('Проверьте SAC: type=rdp.login.success, hostname ITIS198, время входа, event_id из login_monitor.log.')
|
|
||||||
|
[void]$report.Append((Write-Section '10. RCM Operational 1149'))
|
||||||
|
$recent1149 = @()
|
||||||
|
try {
|
||||||
|
$recent1149 = @(Get-WinEvent -FilterHashtable @{
|
||||||
|
LogName = $RcmLogName
|
||||||
|
ID = 1149
|
||||||
|
StartTime = $since
|
||||||
|
} -ErrorAction SilentlyContinue)
|
||||||
|
} catch { }
|
||||||
|
[void]$report.AppendLine("RCM 1149 в окне ($RcmLogName): $($recent1149.Count)")
|
||||||
|
foreach ($ev in $recent1149 | Select-Object -First 8) {
|
||||||
|
$u = '-'; $ip = '-'
|
||||||
|
try {
|
||||||
|
if ($ev.Properties.Count -gt 0) { $u = [string]$ev.Properties[0].Value }
|
||||||
|
if ($ev.Properties.Count -gt 2) { $ip = [string]$ev.Properties[2].Value }
|
||||||
|
} catch { }
|
||||||
|
[void]$report.AppendLine(" $($ev.TimeCreated.ToString('yyyy-MM-dd HH:mm:ss')) User=$u IP=$ip")
|
||||||
|
}
|
||||||
|
$rcmNotifyLines = @()
|
||||||
|
if (Test-Path -LiteralPath $monLog) {
|
||||||
|
$sinceLog = $since.ToString('yyyy-MM-dd HH:mm')
|
||||||
|
$rcmNotifyLines = @(Select-String -LiteralPath $monLog -Pattern 'Notify RCM 1149|Skip 1149' -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object { $_.Line -ge $sinceLog } |
|
||||||
|
Select-Object -ExpandProperty Line)
|
||||||
|
}
|
||||||
|
[void]$report.AppendLine("Строки Notify/Skip 1149 в login_monitor.log: $($rcmNotifyLines.Count)")
|
||||||
|
foreach ($ln in $rcmNotifyLines | Select-Object -Last 10) { [void]$report.AppendLine(" $ln") }
|
||||||
|
if ($recent1149.Count -gt 0 -and $rcmNotifyLines.Count -eq 0) {
|
||||||
|
[void]$report.AppendLine('ВНИМАНИЕ: 1149 в журнале есть, в логе агента нет Notify/Skip — вероятен баг 2.1.4 (все 1149 отбрасывались) или агент не работал в момент входа.')
|
||||||
|
}
|
||||||
|
|
||||||
|
[void]$report.AppendLine('')
|
||||||
|
[void]$report.AppendLine('Проверьте SAC: type=rdp.login.success, hostname, время входа, event_id из login_monitor.log.')
|
||||||
|
|
||||||
$text = $report.ToString()
|
$text = $report.ToString()
|
||||||
[System.IO.File]::WriteAllText($OutputPath, $text, (New-Object System.Text.UTF8Encoding $true))
|
[System.IO.File]::WriteAllText($OutputPath, $text, (New-Object System.Text.UTF8Encoding $true))
|
||||||
|
|||||||
@@ -2,13 +2,38 @@
|
|||||||
|
|
||||||
Скрипт **`update-rdp-monitor.ps1`** на сервере публикации (например DC3) выполняет `git pull` и копирует файлы в шару.
|
Скрипт **`update-rdp-monitor.ps1`** на сервере публикации (например DC3) выполняет `git pull` и копирует файлы в шару.
|
||||||
|
|
||||||
|
## Путь на шаре (`-NetlogonDest`)
|
||||||
|
|
||||||
|
После `git pull` скрипт копирует дистрибутив в UNC-каталог NETLOGON:
|
||||||
|
|
||||||
|
```text
|
||||||
|
\\<имя-DC>\NETLOGON\RDP-login-monitor
|
||||||
|
```
|
||||||
|
|
||||||
|
`<имя-DC>` — NetBIOS-имя или FQDN **контроллера домена**, где лежит SYSVOL (тот же хост, с которого GPO запускает `Deploy-LoginMonitor.ps1`). Примеры:
|
||||||
|
|
||||||
|
- `\\K6A-DC3\NETLOGON\RDP-login-monitor`
|
||||||
|
- `\\dc01.corp.example.com\NETLOGON\RDP-login-monitor`
|
||||||
|
|
||||||
|
Значение по умолчанию в скрипте — **заглушка** `\\dc.contoso.local\NETLOGON\RDP-login-monitor`. В реальном домене её **нужно переопределить**, иначе после успешного `git pull` будет ошибка **«Не найден сетевой путь»** (скрипт не достучится до несуществующего хоста).
|
||||||
|
|
||||||
|
Проверка перед публикацией:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Test-Path '\\K6A-DC3\NETLOGON\RDP-login-monitor'
|
||||||
|
# или хотя бы корень шары:
|
||||||
|
Test-Path '\\K6A-DC3\NETLOGON'
|
||||||
|
```
|
||||||
|
|
||||||
|
Должно вернуть `True` под учётной записью, с которой запускаете публикацию (на DC — обычно локальный админ; с рабочей станции — доменный админ с доступом к NETLOGON).
|
||||||
|
|
||||||
## Параметры по умолчанию
|
## Параметры по умолчанию
|
||||||
|
|
||||||
| Параметр | Значение |
|
| Параметр | Значение |
|
||||||
|----------|----------|
|
|----------|----------|
|
||||||
| `$RepoPath` | `C:\Soft\Git\RDP-login-monitor` |
|
| `$RepoPath` | `C:\Soft\Git\RDP-login-monitor` |
|
||||||
| `$NetlogonDest` | `\\b26\NETLOGON\RDP-login-monitor` |
|
| `$NetlogonDest` | `\\dc.contoso.local\NETLOGON\RDP-login-monitor` *(заглушка — замените на свой DC)* |
|
||||||
| `$GitUrl` | `https://git.kalinamall.ru/PapaTramp/RDP-login-monitor.git` |
|
| `$GitUrl` | `https://github.com/PTah/RDP-login-monitor.git` |
|
||||||
| `$GitBranch` | `main` |
|
| `$GitBranch` | `main` |
|
||||||
| `$LogFile` | `C:\soft\Logs\update-rdp-monitor.log` |
|
| `$LogFile` | `C:\soft\Logs\update-rdp-monitor.log` |
|
||||||
|
|
||||||
@@ -28,9 +53,27 @@
|
|||||||
|
|
||||||
## Запуск
|
## Запуск
|
||||||
|
|
||||||
|
**На сервере публикации** (клон репозитория + доступ к NETLOGON):
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\soft\update-rdp-monitor.ps1
|
powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\Soft\Git\RDP-login-monitor\update-rdp-monitor.ps1 `
|
||||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\soft\update-rdp-monitor.ps1 -WhatIf
|
-NetlogonDest '\\K6A-DC3\NETLOGON\RDP-login-monitor'
|
||||||
|
```
|
||||||
|
|
||||||
|
Другой remote git (закрытое зеркало):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\Soft\Git\RDP-login-monitor\update-rdp-monitor.ps1 `
|
||||||
|
-NetlogonDest '\\K6A-DC3\NETLOGON\RDP-login-monitor' `
|
||||||
|
-GitUrl 'https://git.kalinamall.ru/PapaTramp/RDP-login-monitor.git'
|
||||||
|
```
|
||||||
|
|
||||||
|
Пробный прогон без копирования:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\Soft\Git\RDP-login-monitor\update-rdp-monitor.ps1 `
|
||||||
|
-NetlogonDest '\\K6A-DC3\NETLOGON\RDP-login-monitor' `
|
||||||
|
-WhatIf
|
||||||
```
|
```
|
||||||
|
|
||||||
После pull обязательно проверьте **`version.txt`** на шаре — его номер определяет, подтянут ли обновления на клиентах. Метка может включать суффикс (например **`1.2.27-SAC`**); **`Deploy-LoginMonitor.ps1`** сравнивает её с **`deployed_version.txt`** по полной строке.
|
После pull обязательно проверьте **`version.txt`** на шаре — его номер определяет, подтянут ли обновления на клиентах. Метка может включать суффикс (например **`1.2.27-SAC`**); **`Deploy-LoginMonitor.ps1`** сравнивает её с **`deployed_version.txt`** по полной строке.
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ flowchart TD
|
|||||||
**Мониторинг очередей транспорта и правил пересылки** — другой скрипт, **не** через GPO RDP-монитора:
|
**Мониторинг очередей транспорта и правил пересылки** — другой скрипт, **не** через GPO RDP-монитора:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "\\B26\NETLOGON\RDP-login-monitor\Deploy-DomainMonitors.ps1" -Target Exchange
|
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "\\dc.contoso.local\NETLOGON\RDP-login-monitor\Deploy-DomainMonitors.ps1" -Target Exchange
|
||||||
```
|
```
|
||||||
|
|
||||||
Подробно: [exchange-mail-security.md](exchange-mail-security.md).
|
Подробно: [exchange-mail-security.md](exchange-mail-security.md).
|
||||||
@@ -105,7 +105,7 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File "\\B26\NETLOGON\RDP-logi
|
|||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
$root = 'C:\ProgramData\RDP-login-monitor'
|
$root = 'C:\ProgramData\RDP-login-monitor'
|
||||||
Copy-Item '\\B26\NETLOGON\RDP-login-monitor\login_monitor.settings.example.ps1' `
|
Copy-Item '\\dc.contoso.local\NETLOGON\RDP-login-monitor\login_monitor.settings.example.ps1' `
|
||||||
(Join-Path $root 'login_monitor.settings.ps1')
|
(Join-Path $root 'login_monitor.settings.ps1')
|
||||||
notepad (Join-Path $root 'login_monitor.settings.ps1')
|
notepad (Join-Path $root 'login_monitor.settings.ps1')
|
||||||
```
|
```
|
||||||
@@ -116,7 +116,7 @@ DPAPI: **`Encrypt-DpapiForRdpMonitor.ps1`**.
|
|||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
$UseSAC = 'dual' # off | exclusive | dual | fallback
|
$UseSAC = 'dual' # off | exclusive | dual | fallback
|
||||||
$SacUrl = 'https://sac.kalinamall.ru'
|
$SacUrl = 'https://sac.example.com'
|
||||||
$SacApiKey = 'sac_...'
|
$SacApiKey = 'sac_...'
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -137,7 +137,7 @@ $SacApiKey = 'sac_...'
|
|||||||
|
|
||||||
## GPO и периодический deploy
|
## GPO и периодический deploy
|
||||||
|
|
||||||
1. Файлы на `\\B26\NETLOGON\RDP-login-monitor\` (`update-rdp-monitor.ps1` на DC публикации).
|
1. Файлы на `\\dc.contoso.local\NETLOGON\RDP-login-monitor\` (`update-rdp-monitor.ps1` на DC публикации).
|
||||||
2. GPO на OU **компьютеров** → **Сценарии PowerShell** автозагрузки → `Deploy-LoginMonitor.ps1`.
|
2. GPO на OU **компьютеров** → **Сценарии PowerShell** автозагрузки → `Deploy-LoginMonitor.ps1`.
|
||||||
3. Security Filtering: группа компьютеров; на шару — Read для **SYSTEM** / Domain Computers.
|
3. Security Filtering: группа компьютеров; на шару — Read для **SYSTEM** / Domain Computers.
|
||||||
4. После смены membership — **перезагрузка** (не только `gpupdate`).
|
4. После смены membership — **перезагрузка** (не только `gpupdate`).
|
||||||
@@ -149,7 +149,7 @@ Deploy после `-InstallTasks` запускает монитор; задач
|
|||||||
```powershell
|
```powershell
|
||||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\Install-DeployScheduledTask.ps1" `
|
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\Install-DeployScheduledTask.ps1" `
|
||||||
-TaskName "RDP-Login-Monitor-Deploy" `
|
-TaskName "RDP-Login-Monitor-Deploy" `
|
||||||
-DeployScriptPath "\\B26\NETLOGON\RDP-login-monitor\Deploy-LoginMonitor.ps1" `
|
-DeployScriptPath "\\dc.contoso.local\NETLOGON\RDP-login-monitor\Deploy-LoginMonitor.ps1" `
|
||||||
-RepeatMinutes 60 `
|
-RepeatMinutes 60 `
|
||||||
-RunNow
|
-RunNow
|
||||||
```
|
```
|
||||||
@@ -157,7 +157,7 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\Install-DeploySchedul
|
|||||||
**Ручной deploy** (от администратора):
|
**Ручной deploy** (от администратора):
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "\\B26\NETLOGON\RDP-login-monitor\Deploy-LoginMonitor.ps1"
|
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "\\dc.contoso.local\NETLOGON\RDP-login-monitor\Deploy-LoginMonitor.ps1"
|
||||||
```
|
```
|
||||||
|
|
||||||
Параметры: **`-WhatIf`**, **`-SkipStartMonitorAfterUpdate`**, **`-AllowDowngrade`**.
|
Параметры: **`-WhatIf`**, **`-SkipStartMonitorAfterUpdate`**, **`-AllowDowngrade`**.
|
||||||
@@ -203,6 +203,21 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass `
|
|||||||
|
|
||||||
**`Deploy-LoginMonitor.ps1`** пишет **`restart.request`**, ждёт до **35 с**, затем **`Stop-Process -Force`** только при таймауте.
|
**`Deploy-LoginMonitor.ps1`** пишет **`restart.request`**, ждёт до **35 с**, затем **`Stop-Process -Force`** только при таймауте.
|
||||||
|
|
||||||
|
## Обновление через SAC (WinRM)
|
||||||
|
|
||||||
|
Альтернатива NETLOGON/GPO: кнопка **«Обновить через WinRM»** на карточке хоста в SAC (сервер ≥ 0.20.15).
|
||||||
|
|
||||||
|
| Этап | Где | Действие |
|
||||||
|
|------|-----|----------|
|
||||||
|
| 1 | SAC | `git fetch` RDP-login-monitor → zip (`.ps1` с UTF-8 BOM) |
|
||||||
|
| 2 | Клиент (WinRM) | `Invoke-WebRequest` → `/api/v1/agent/rdp-bundle/<token>` |
|
||||||
|
| 3 | Клиент | Распаковка в `C:\ProgramData\RDP-login-monitor\_sac_staging` |
|
||||||
|
| 4 | Клиент | `Deploy-LoginMonitor.ps1 -SourceShareRoot _sac_staging` |
|
||||||
|
|
||||||
|
**Требования:** domain admin в SAC; `SAC_PUBLIC_URL` доступен с ПК; `rdp_git_repo_url` в настройках обновлений. **`Deploy-LoginMonitor.ps1` на шаре NETLOGON для этого пути не обязателен** — скрипт приходит в zip.
|
||||||
|
|
||||||
|
Лог операции — в модалке SAC сразу при старте. См. [agent-control-plane.md](https://git.kalinamall.ru/PapaTramp/security-alert-center/src/branch/main/docs/agent-control-plane.md) §4.3.
|
||||||
|
|
||||||
## Версии
|
## Версии
|
||||||
|
|
||||||
| Файл | Роль |
|
| Файл | Роль |
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Exchange Mail Security — руководство
|
# Exchange Mail Security — руководство
|
||||||
|
|
||||||
Скрипт **`Exchange-MailSecurity.ps1`** предназначен **только для сервера Microsoft Exchange** с Exchange Management Shell (EMS). Не устанавливается на все компьютеры домена через GPO RDP-монитора.
|
Скрипт **`Exchange-MailSecurity.ps1`** предназначен **только для сервера Microsoft Exchange** с Exchange Management Shell (EMS). Не устанавливается на все компьютеры домена через GPO RDP-монитора.
|
||||||
|
|
||||||
@@ -68,6 +68,16 @@
|
|||||||
- Каналы: **Telegram** и/или **Email** (модуль **`Notify-Common.ps1`**).
|
- Каналы: **Telegram** и/или **Email** (модуль **`Notify-Common.ps1`**).
|
||||||
- Пересылка: **`$AlertOnlyOnNewForwardingFindings = $true`** — алерт при **новой** находке (`Logs\exchange_forwarding_baseline.json`).
|
- Пересылка: **`$AlertOnlyOnNewForwardingFindings = $true`** — алерт при **новой** находке (`Logs\exchange_forwarding_baseline.json`).
|
||||||
- **Первый скан:** **`$SuppressAlertsOnFirstBaselineRun = $true`** (по умолчанию) — существующие пересылки **только в baseline**, без всплеска алертов; одна **сводка** (`$SendInboxScanSummary`).
|
- **Первый скан:** **`$SuppressAlertsOnFirstBaselineRun = $true`** (по умолчанию) — существующие пересылки **только в baseline**, без всплеска алертов; одна **сводка** (`$SendInboxScanSummary`).
|
||||||
|
|
||||||
|
### Dry-run перед первым Inbox-сканом
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "\\dc.contoso.local\NETLOGON\RDP-login-monitor\Exchange-MailSecurity.ps1" -Mode Inbox -WhatIf
|
||||||
|
```
|
||||||
|
|
||||||
|
`-WhatIf` подключает EMS, считает объём (`Get-Mailbox` / VIP-фильтр), **не вызывает** `Get-InboxRule`, не шлёт уведомления и не пишет baseline. Рекомендуется перед `-InstallTasks` и первым ночным `-Mode Inbox`.
|
||||||
|
|
||||||
|
При полном скане без VIP скрипт пишет **WARN** в лог; проблемные ящики — в **`$SkipInboxScanMailboxes`**.
|
||||||
- Далее — алерт только при **новых** или **изменённых** пересылках (в т.ч. включили ранее отключённое правило).
|
- Далее — алерт только при **новых** или **изменённых** пересылках (в т.ч. включили ранее отключённое правило).
|
||||||
- **`$NotifyWhenForwardingScanClean = $false`** — не слать «всё чисто» при нуле находок.
|
- **`$NotifyWhenForwardingScanClean = $false`** — не слать «всё чисто» при нуле находок.
|
||||||
|
|
||||||
@@ -106,7 +116,7 @@ $VipMailboxPatterns = @('*@domain.ru') # опционально
|
|||||||
### 2. Деплой на Exchange (от администратора)
|
### 2. Деплой на Exchange (от администратора)
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "\\B26\NETLOGON\RDP-login-monitor\Deploy-DomainMonitors.ps1" -Target Exchange
|
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "\\dc.contoso.local\NETLOGON\RDP-login-monitor\Deploy-DomainMonitors.ps1" -Target Exchange
|
||||||
```
|
```
|
||||||
|
|
||||||
Скрипт копирует файлы в `C:\ProgramData\RDP-login-monitor\`, вызывает **`Install-DomainMonitors.ps1 -Target Exchange`**, который регистрирует задачи планировщика.
|
Скрипт копирует файлы в `C:\ProgramData\RDP-login-monitor\`, вызывает **`Install-DomainMonitors.ps1 -Target Exchange`**, который регистрирует задачи планировщика.
|
||||||
@@ -120,7 +130,7 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File "\\B26\NETLOGON\RDP-logi
|
|||||||
$ex = 'C:\ProgramData\RDP-login-monitor'
|
$ex = 'C:\ProgramData\RDP-login-monitor'
|
||||||
$src = Join-Path $ex 'exchange_monitor.settings.example.ps1'
|
$src = Join-Path $ex 'exchange_monitor.settings.example.ps1'
|
||||||
if (-not (Test-Path -LiteralPath $src)) {
|
if (-not (Test-Path -LiteralPath $src)) {
|
||||||
$src = '\\B26\NETLOGON\RDP-login-monitor\exchange_monitor.settings.example.ps1'
|
$src = '\\dc.contoso.local\NETLOGON\RDP-login-monitor\exchange_monitor.settings.example.ps1'
|
||||||
}
|
}
|
||||||
Copy-Item -LiteralPath $src -Destination (Join-Path $ex 'exchange_monitor.settings.ps1')
|
Copy-Item -LiteralPath $src -Destination (Join-Path $ex 'exchange_monitor.settings.ps1')
|
||||||
notepad (Join-Path $ex 'exchange_monitor.settings.ps1')
|
notepad (Join-Path $ex 'exchange_monitor.settings.ps1')
|
||||||
@@ -212,7 +222,7 @@ schtasks /Query /TN "RDP-Exchange-MailSecurity-Watchdog" /V /FO LIST
|
|||||||
| `$InboxScanBatchSize` | 50 | Пауза каждые N ящиков |
|
| `$InboxScanBatchSize` | 50 | Пауза каждые N ящиков |
|
||||||
| `$InboxScanBatchDelaySeconds` | 3 | Задержка между батчами |
|
| `$InboxScanBatchDelaySeconds` | 3 | Задержка между батчами |
|
||||||
| `$ExcludeMailboxPatterns` | HealthMailbox*, … | Исключения |
|
| `$ExcludeMailboxPatterns` | HealthMailbox*, … | Исключения |
|
||||||
| `$SkipInboxScanMailboxes` | `k.selezneva@kalinamall.ru` | Не вызывать `Get-InboxRule` (битый rule store) |
|
| `$SkipInboxScanMailboxes` | `k.selezneva@example.com` | Не вызывать `Get-InboxRule` (битый rule store) |
|
||||||
|
|
||||||
Переопределение — в **`exchange_monitor.settings.ps1`**.
|
Переопределение — в **`exchange_monitor.settings.ps1`**.
|
||||||
|
|
||||||
@@ -225,7 +235,7 @@ schtasks /Query /TN "RDP-Exchange-MailSecurity-Watchdog" /V /FO LIST
|
|||||||
```text
|
```text
|
||||||
📧 Exchange: пересылка на внешний адрес
|
📧 Exchange: пересылка на внешний адрес
|
||||||
Тип: InboxRule | MailboxForwarding | TransportRule
|
Тип: InboxRule | MailboxForwarding | TransportRule
|
||||||
Ящик: user@kalinamall.ru
|
Ящик: user@example.com
|
||||||
Правило: …
|
Правило: …
|
||||||
Куда: attacker@gmail.com (внешний)
|
Куда: attacker@gmail.com (внешний)
|
||||||
Важность: Критическая | Высокая
|
Важность: Критическая | Высокая
|
||||||
@@ -238,7 +248,7 @@ schtasks /Query /TN "RDP-Exchange-MailSecurity-Watchdog" /V /FO LIST
|
|||||||
3. На Exchange:
|
3. На Exchange:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "\\B26\NETLOGON\RDP-login-monitor\Deploy-DomainMonitors.ps1" -Target Exchange
|
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "\\dc.contoso.local\NETLOGON\RDP-login-monitor\Deploy-DomainMonitors.ps1" -Target Exchange
|
||||||
```
|
```
|
||||||
|
|
||||||
## Устранение неполадок
|
## Устранение неполадок
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Запуск: от администратора на ТОМ ЖЕ компьютере, где будет Login_Monitor.ps1.
|
# Запуск: от администратора на ТОМ ЖЕ компьютере, где будет Login_Monitor.ps1.
|
||||||
# Результат (Base64) вставьте в login_monitor.settings.ps1 или exchange_monitor.settings.ps1:
|
# Результат (Base64) вставьте в login_monitor.settings.ps1 или exchange_monitor.settings.ps1:
|
||||||
# $TelegramBotTokenProtectedB64 / $TelegramChatIDProtectedB64 / $MailSmtpPasswordProtectedB64.
|
# $TelegramBotTokenProtectedB64 / $TelegramChatIDProtectedB64 / $MailSmtpPasswordProtectedB64.
|
||||||
param(
|
param(
|
||||||
|
|||||||
@@ -10,12 +10,13 @@
|
|||||||
Опционально: exchange_monitor.settings.ps1 в том же каталоге (секреты, whitelist).
|
Опционально: exchange_monitor.settings.ps1 в том же каталоге (секреты, whitelist).
|
||||||
#>
|
#>
|
||||||
|
|
||||||
[CmdletBinding()]
|
[CmdletBinding(SupportsShouldProcess = $true)]
|
||||||
param(
|
param(
|
||||||
[ValidateSet('Queues', 'Inbox', 'Watchdog')]
|
[ValidateSet('Queues', 'Inbox', 'Watchdog')]
|
||||||
[string]$Mode = 'Queues',
|
[string]$Mode = 'Queues',
|
||||||
[switch]$InstallTasks,
|
[switch]$InstallTasks,
|
||||||
[switch]$Watchdog
|
[switch]$Watchdog,
|
||||||
|
[switch]$WhatIf
|
||||||
)
|
)
|
||||||
|
|
||||||
Set-StrictMode -Version Latest
|
Set-StrictMode -Version Latest
|
||||||
@@ -25,7 +26,7 @@ $ErrorActionPreference = 'Stop'
|
|||||||
# КОНФИГУРАЦИЯ
|
# КОНФИГУРАЦИЯ
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
$ScriptVersion = '1.6.7'
|
$ScriptVersion = '1.6.8'
|
||||||
$script:InstallRoot = [System.IO.Path]::GetFullPath("$env:ProgramData\RDP-login-monitor")
|
$script:InstallRoot = [System.IO.Path]::GetFullPath("$env:ProgramData\RDP-login-monitor")
|
||||||
$script:CanonicalScriptName = 'Exchange-MailSecurity.ps1'
|
$script:CanonicalScriptName = 'Exchange-MailSecurity.ps1'
|
||||||
$LogFile = Join-Path $script:InstallRoot 'Logs\exchange_mail_security.log'
|
$LogFile = Join-Path $script:InstallRoot 'Logs\exchange_mail_security.log'
|
||||||
@@ -63,11 +64,11 @@ $ScanTransportRules = $true
|
|||||||
# VIP: только перечисленные ящики и/или шаблоны (пилот перед полным сканом).
|
# VIP: только перечисленные ящики и/или шаблоны (пилот перед полным сканом).
|
||||||
$VipMailboxesOnly = $false
|
$VipMailboxesOnly = $false
|
||||||
$VipMailboxes = @() # точные PrimarySmtpAddress: user@domain.com
|
$VipMailboxes = @() # точные PrimarySmtpAddress: user@domain.com
|
||||||
$VipMailboxPatterns = @() # wildcard: *@kalinamall.ru, director*, finance*
|
$VipMailboxPatterns = @() # wildcard: *@example.com, director*, finance*
|
||||||
$ExcludeMailboxPatterns = @('HealthMailbox*', 'DiscoveryMailbox*', 'SystemMailbox*')
|
$ExcludeMailboxPatterns = @('HealthMailbox*', 'DiscoveryMailbox*', 'SystemMailbox*')
|
||||||
# Skip Inbox rules scan (corrupt rule store / Get-InboxRule fails). Override in exchange_monitor.settings.ps1
|
# Skip Inbox rules scan (corrupt rule store / Get-InboxRule fails). Override in exchange_monitor.settings.ps1
|
||||||
$SkipInboxScanMailboxes = @(
|
$SkipInboxScanMailboxes = @(
|
||||||
'k.selezneva@kalinamall.ru'
|
'broken-mailbox@example.com'
|
||||||
)
|
)
|
||||||
$ScanDisabledInboxRulesWithExternalForward = $true # отключённые правила с внешней пересылкой
|
$ScanDisabledInboxRulesWithExternalForward = $true # отключённые правила с внешней пересылкой
|
||||||
$SuppressAlertsOnFirstBaselineRun = $true # первый скан: baseline без всплеска алертов
|
$SuppressAlertsOnFirstBaselineRun = $true # первый скан: baseline без всплеска алертов
|
||||||
@@ -100,6 +101,18 @@ if (Test-Path -LiteralPath $SettingsFile) {
|
|||||||
. $SettingsFile
|
. $SettingsFile
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Write-ExchangeScanSafetyWarnings {
|
||||||
|
if (-not $SuppressAlertsOnFirstBaselineRun) {
|
||||||
|
Write-ExchLog 'WARN: SuppressAlertsOnFirstBaselineRun=$false — первый Inbox-скан может разослать алерты по всем уже существующим пересылкам.'
|
||||||
|
}
|
||||||
|
if (-not $VipMailboxesOnly -and $MaxMailboxesPerRun -le 0 -and $ScanInboxRules) {
|
||||||
|
Write-ExchLog 'WARN: полный скан Inbox rules по всем ящикам (VipMailboxesOnly=$false). Рекомендуется пилот: VipMailboxesOnly=$true или -Mode Inbox -WhatIf.'
|
||||||
|
}
|
||||||
|
if (@($SkipInboxScanMailboxes | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }).Count -eq 0) {
|
||||||
|
Write-ExchLog 'TIP: добавьте проблемные ящики в $SkipInboxScanMailboxes, если Get-InboxRule падает (corrupt rule store).'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function Write-NotifyLog {
|
function Write-NotifyLog {
|
||||||
param([string]$Message)
|
param([string]$Message)
|
||||||
Write-ExchLog $Message
|
Write-ExchLog $Message
|
||||||
@@ -235,6 +248,7 @@ if ($InstallTasks) {
|
|||||||
Send-ExchangeInstallNotification
|
Send-ExchangeInstallNotification
|
||||||
Write-ExchLog 'InstallTasks: install notification sent'
|
Write-ExchLog 'InstallTasks: install notification sent'
|
||||||
}
|
}
|
||||||
|
Write-ExchLog 'InstallTasks: перед первым ночным Inbox-сканом выполните: Exchange-MailSecurity.ps1 -Mode Inbox -WhatIf'
|
||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -518,6 +532,15 @@ function Invoke-ExchangeQueueScan {
|
|||||||
Write-ExchLog "Queues: threshold MessageCount > $QueueMessageCountThreshold"
|
Write-ExchLog "Queues: threshold MessageCount > $QueueMessageCountThreshold"
|
||||||
|
|
||||||
$queues = @(Get-Queue -ErrorAction Stop)
|
$queues = @(Get-Queue -ErrorAction Stop)
|
||||||
|
if ($WhatIf) {
|
||||||
|
$hot = @($queues | Where-Object { $_.MessageCount -gt $QueueMessageCountThreshold })
|
||||||
|
Write-ExchLog "WhatIf: queues total=$($queues.Count), above threshold=$($hot.Count) — alerts skipped"
|
||||||
|
foreach ($q in $hot) {
|
||||||
|
Write-ExchLog "WhatIf: would alert queue=$($q.Identity) messages=$($q.MessageCount)"
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
$hot = @($queues | Where-Object { $_.MessageCount -gt $QueueMessageCountThreshold })
|
$hot = @($queues | Where-Object { $_.MessageCount -gt $QueueMessageCountThreshold })
|
||||||
$state = Get-QueueAlertState
|
$state = Get-QueueAlertState
|
||||||
$now = Get-Date
|
$now = Get-Date
|
||||||
@@ -760,6 +783,20 @@ function Send-ExchangeInboxScanSummary {
|
|||||||
function Invoke-ExchangeInboxAndForwardingScan {
|
function Invoke-ExchangeInboxAndForwardingScan {
|
||||||
$scopeLabel = Get-ExchangeInboxScanScopeLabel
|
$scopeLabel = Get-ExchangeInboxScanScopeLabel
|
||||||
Write-ExchLog "Inbox/Forwarding scan v$ScriptVersion; scope: $scopeLabel; notify: $(Get-NotifyChainHuman)"
|
Write-ExchLog "Inbox/Forwarding scan v$ScriptVersion; scope: $scopeLabel; notify: $(Get-NotifyChainHuman)"
|
||||||
|
if ($WhatIf) {
|
||||||
|
$null = Import-ExchangeManagementShell
|
||||||
|
$internalDomains = Get-InternalAcceptedDomainNames
|
||||||
|
Write-ExchLog "WhatIf: accepted domains: $(@($internalDomains) -join ', ')"
|
||||||
|
$mailboxCount = 0
|
||||||
|
if ($ScanInboxRules) {
|
||||||
|
$mailboxes = @(Get-MailboxListForScan)
|
||||||
|
$mailboxCount = $mailboxes.Count
|
||||||
|
Write-ExchLog "WhatIf: would scan $mailboxCount mailboxes ($scopeLabel); Get-InboxRule not called"
|
||||||
|
}
|
||||||
|
Write-ExchLog "WhatIf: ScanMailboxForwarding=$ScanMailboxForwarding ScanTransportRules=$ScanTransportRules — no alerts, no baseline write"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
$null = Import-ExchangeManagementShell
|
$null = Import-ExchangeManagementShell
|
||||||
$internalDomains = Get-InternalAcceptedDomainNames
|
$internalDomains = Get-InternalAcceptedDomainNames
|
||||||
Write-ExchLog "Accepted domains (internal): $(@($internalDomains) -join ', ')"
|
Write-ExchLog "Accepted domains (internal): $(@($internalDomains) -join ', ')"
|
||||||
@@ -879,7 +916,8 @@ if (-not (Test-RunningElevated)) {
|
|||||||
Write-ExchLog 'WARNING: not running elevated - EMS/tasks may fail.'
|
Write-ExchLog 'WARNING: not running elevated - EMS/tasks may fail.'
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-ExchLog "=== Exchange-MailSecurity v$ScriptVersion Mode=$Mode ==="
|
Write-ExchLog "=== Exchange-MailSecurity v$ScriptVersion Mode=$Mode$(if ($WhatIf) { ' WhatIf' }) ==="
|
||||||
|
Write-ExchangeScanSafetyWarnings
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch ($Mode) {
|
switch ($Mode) {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
#Requires -RunAsAdministrator
|
#Requires -RunAsAdministrator
|
||||||
[CmdletBinding()]
|
[CmdletBinding()]
|
||||||
param(
|
param(
|
||||||
[string]$TaskName = "RDP-Login-Monitor-Deploy",
|
[string]$TaskName = "RDP-Login-Monitor-Deploy",
|
||||||
[string]$DeployScriptPath = "\\B26\NETLOGON\RDP-login-monitor\Deploy-LoginMonitor.ps1",
|
[string]$DeployScriptPath = "\\dc.contoso.local\NETLOGON\RDP-login-monitor\Deploy-LoginMonitor.ps1",
|
||||||
[ValidateRange(5, 1440)][int]$RepeatMinutes = 60,
|
[ValidateRange(5, 1440)][int]$RepeatMinutes = 60,
|
||||||
[switch]$RunNow
|
[switch]$RunNow
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
#Requires -RunAsAdministrator
|
|
||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
Регистрирует в Планировщике заданий основной монитор и watchdog (как в README).
|
|
||||||
.DESCRIPTION
|
|
||||||
Запускайте из повышенной PowerShell. Пути по умолчанию — D:\Soft\.
|
|
||||||
Watchdog использует Watchdog_RDP_Monitor.ps1 из репозитория (проверка процесса и heartbeat).
|
|
||||||
#>
|
|
||||||
[CmdletBinding()]
|
|
||||||
param(
|
|
||||||
[string]$InstallRoot = "D:\Soft",
|
|
||||||
[string]$MainTaskName = "RDP Login Monitor",
|
|
||||||
[string]$WatchdogTaskName = "RDP Login Monitor Watchdog",
|
|
||||||
[int]$WatchdogRepeatMinutes = 5,
|
|
||||||
[int]$MainStartupRandomDelayMinutes = 1,
|
|
||||||
[int]$WatchdogStartupRandomDelayMinutes = 2
|
|
||||||
)
|
|
||||||
|
|
||||||
Set-StrictMode -Version Latest
|
|
||||||
$ErrorActionPreference = "Stop"
|
|
||||||
|
|
||||||
$LoginScriptPath = Join-Path $InstallRoot "Login_Monitor.ps1"
|
|
||||||
$WatchdogScriptPath = Join-Path $InstallRoot "Watchdog_RDP_Monitor.ps1"
|
|
||||||
$LogsDir = Join-Path $InstallRoot "Logs"
|
|
||||||
|
|
||||||
if (-not (Test-Path -LiteralPath $LoginScriptPath)) {
|
|
||||||
throw "Не найден основной скрипт: $LoginScriptPath"
|
|
||||||
}
|
|
||||||
if (-not (Test-Path -LiteralPath $WatchdogScriptPath)) {
|
|
||||||
throw "Не найден watchdog: $WatchdogScriptPath"
|
|
||||||
}
|
|
||||||
if (-not (Test-Path -LiteralPath $LogsDir)) {
|
|
||||||
New-Item -ItemType Directory -Path $LogsDir -Force | Out-Null
|
|
||||||
}
|
|
||||||
|
|
||||||
$principal = New-ScheduledTaskPrincipal `
|
|
||||||
-UserId "NT AUTHORITY\SYSTEM" `
|
|
||||||
-LogonType ServiceAccount `
|
|
||||||
-RunLevel Highest
|
|
||||||
|
|
||||||
# --- Задание 1: основной монитор ---
|
|
||||||
$mainArgs = "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$LoginScriptPath`""
|
|
||||||
$mainAction = New-ScheduledTaskAction -Execute "PowerShell.exe" -Argument $mainArgs
|
|
||||||
$mainTrigger = New-ScheduledTaskTrigger -AtStartup -RandomDelay (New-TimeSpan -Minutes $MainStartupRandomDelayMinutes)
|
|
||||||
$mainSettings = New-ScheduledTaskSettingsSet `
|
|
||||||
-AllowStartIfOnBatteries `
|
|
||||||
-DontStopIfGoingOnBatteries `
|
|
||||||
-StartWhenAvailable `
|
|
||||||
-MultipleInstances IgnoreNew `
|
|
||||||
-ExecutionTimeLimit ([TimeSpan]::Zero)
|
|
||||||
|
|
||||||
Register-ScheduledTask `
|
|
||||||
-TaskName $MainTaskName `
|
|
||||||
-Action $mainAction `
|
|
||||||
-Trigger $mainTrigger `
|
|
||||||
-Principal $principal `
|
|
||||||
-Settings $mainSettings `
|
|
||||||
-Force | Out-Null
|
|
||||||
|
|
||||||
Write-Host "Создано задание: $MainTaskName" -ForegroundColor Cyan
|
|
||||||
|
|
||||||
# --- Задание 2: watchdog (старт + периодический запуск, как в README) ---
|
|
||||||
$wdArgs = "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$WatchdogScriptPath`""
|
|
||||||
$wdAction = New-ScheduledTaskAction -Execute "PowerShell.exe" -Argument $wdArgs
|
|
||||||
$wdTriggerStartup = New-ScheduledTaskTrigger -AtStartup -RandomDelay (New-TimeSpan -Minutes $WatchdogStartupRandomDelayMinutes)
|
|
||||||
$repeatDuration = New-TimeSpan -Days 3650
|
|
||||||
$anchor = (Get-Date).AddMinutes([Math]::Max(3, $WatchdogRepeatMinutes))
|
|
||||||
$wdTriggerRepeat = New-ScheduledTaskTrigger -Once -At $anchor `
|
|
||||||
-RepetitionInterval (New-TimeSpan -Minutes $WatchdogRepeatMinutes) `
|
|
||||||
-RepetitionDuration $repeatDuration
|
|
||||||
|
|
||||||
$wdSettings = New-ScheduledTaskSettingsSet `
|
|
||||||
-AllowStartIfOnBatteries `
|
|
||||||
-DontStopIfGoingOnBatteries `
|
|
||||||
-StartWhenAvailable `
|
|
||||||
-MultipleInstances IgnoreNew
|
|
||||||
|
|
||||||
Register-ScheduledTask `
|
|
||||||
-TaskName $WatchdogTaskName `
|
|
||||||
-Action $wdAction `
|
|
||||||
-Trigger @($wdTriggerStartup, $wdTriggerRepeat) `
|
|
||||||
-Principal $principal `
|
|
||||||
-Settings $wdSettings `
|
|
||||||
-Force | Out-Null
|
|
||||||
|
|
||||||
Write-Host "Создано задание: $WatchdogTaskName (триггеры: при старте ОС и каждые $WatchdogRepeatMinutes мин.)" -ForegroundColor Cyan
|
|
||||||
Write-Host "Готово. При необходимости сразу запустите: Start-ScheduledTask -TaskName '$MainTaskName'" -ForegroundColor Green
|
|
||||||
+597
-34
@@ -16,7 +16,7 @@
|
|||||||
- Добавлены исключения шума: DWM-*, UMFD-*, HealthMailbox*, Font Driver Host*, NtLmSsp и др.
|
- Добавлены исключения шума: DWM-*, UMFD-*, HealthMailbox*, Font Driver Host*, NtLmSsp и др.
|
||||||
- Heartbeat без дрейфа: используется nextHeartbeatTime (а не "прошло N секунд").
|
- Heartbeat без дрейфа: используется nextHeartbeatTime (а не "прошло N секунд").
|
||||||
- Win32_OperatingSystem.ProductType: 1 = рабочая станция (4624/4625 только LogonType 10 + при наличии журнала событие 1149 RCM);
|
- Win32_OperatingSystem.ProductType: 1 = рабочая станция (4624/4625 только LogonType 10 + при наличии журнала событие 1149 RCM);
|
||||||
2/3 = сервер/КД — прежняя логика (типы 2, 3, 10).
|
2/3 = сервер/КД — Security 4624/4625 (типы 2, 3, 10) + при наличии журнала RCM — 1149 (на RDS часто нет 4624 без аудита входа).
|
||||||
Секреты Telegram (DPAPI LocalMachine): на ЭТОЙ машине, под админом, выполните:
|
Секреты Telegram (DPAPI LocalMachine): на ЭТОЙ машине, под админом, выполните:
|
||||||
Add-Type -AssemblyName System.Security
|
Add-Type -AssemblyName System.Security
|
||||||
$p=[Text.Encoding]::UTF8.GetBytes('<токен>')
|
$p=[Text.Encoding]::UTF8.GetBytes('<токен>')
|
||||||
@@ -90,7 +90,7 @@ $script:SkipLogDetailLimit = 15
|
|||||||
# строки ниже, если правки «мелкие» и вы не хотите менять отображаемую версию в логах).
|
# строки ниже, если правки «мелкие» и вы не хотите менять отображаемую версию в логах).
|
||||||
# Рекомендация: при значимых релизах меняйте и $ScriptVersion, и version.txt одинаково; при только
|
# Рекомендация: при значимых релизах меняйте и $ScriptVersion, и version.txt одинаково; при только
|
||||||
# исправлениях на шаре — достаточно поднять patch в version.txt (например 1.3.0.1).
|
# исправлениях на шаре — достаточно поднять patch в version.txt (например 1.3.0.1).
|
||||||
$ScriptVersion = "2.0.34-SAC"
|
$ScriptVersion = "2.1.15-SAC"
|
||||||
|
|
||||||
# Логи (все под InstallRoot)
|
# Логи (все под InstallRoot)
|
||||||
$LogFile = Join-Path $script:InstallRoot "Logs\login_monitor.log"
|
$LogFile = Join-Path $script:InstallRoot "Logs\login_monitor.log"
|
||||||
@@ -103,14 +103,19 @@ $MaxBackupDays = 31
|
|||||||
|
|
||||||
# Heartbeat (файл; при отсутствии обновления > HeartbeatStaleAlertMultiplier × интервал — оповещение)
|
# Heartbeat (файл; при отсутствии обновления > HeartbeatStaleAlertMultiplier × интервал — оповещение)
|
||||||
$HeartbeatInterval = 14400
|
$HeartbeatInterval = 14400
|
||||||
|
$HeartbeatStaleAlertMultiplier = 2
|
||||||
|
# Окно (мин) для определения «старт после перезагрузки ОС» (LastBootUpTime + System 41/1074/6005/6008/6009).
|
||||||
|
$StartupRebootDetectMinutes = 5
|
||||||
# Инвентаризация железа/ПО для SAC (agent.inventory); интервал опроса, сек (12 ч).
|
# Инвентаризация железа/ПО для SAC (agent.inventory); интервал опроса, сек (12 ч).
|
||||||
$InventoryIntervalSec = 43200
|
$InventoryIntervalSec = 43200
|
||||||
$GetInventory = 1
|
$GetInventory = 1
|
||||||
$HeartbeatStaleAlertMultiplier = 2
|
$SacCommandPollIntervalSec = 60
|
||||||
# Один RDP-вход часто даёт 2+ события 4624 с одним временем — не слать дубли в Telegram/SAC.
|
# Один RDP-вход часто даёт 2+ события 4624 с одним временем — не слать дубли в Telegram/SAC.
|
||||||
$LoginSuccessNotifyDedupSeconds = 90
|
$LoginSuccessNotifyDedupSeconds = 90
|
||||||
$HeartbeatFile = Join-Path $script:InstallRoot "Logs\last_heartbeat.txt"
|
$HeartbeatFile = Join-Path $script:InstallRoot "Logs\last_heartbeat.txt"
|
||||||
$GatewayPollCursorFile = Join-Path $script:InstallRoot "Logs\last_rdgateway_poll.txt"
|
$GatewayPollCursorFile = Join-Path $script:InstallRoot "Logs\last_rdgateway_poll.txt"
|
||||||
|
$SecurityPollCursorFile = Join-Path $script:InstallRoot "Logs\last_security_poll.txt"
|
||||||
|
$Rcm1149PollCursorFile = Join-Path $script:InstallRoot "Logs\last_rcm1149_poll.txt"
|
||||||
$DeployUpdateMarkerFile = Join-Path $script:InstallRoot "deploy_last_update.txt"
|
$DeployUpdateMarkerFile = Join-Path $script:InstallRoot "deploy_last_update.txt"
|
||||||
# Построчные правила подавления уведомлений Security 4624/4625 (см. ignore.lst.example в репозитории).
|
# Построчные правила подавления уведомлений Security 4624/4625 (см. ignore.lst.example в репозитории).
|
||||||
$script:IgnoreListPath = Join-Path $script:InstallRoot "ignore.lst"
|
$script:IgnoreListPath = Join-Path $script:InstallRoot "ignore.lst"
|
||||||
@@ -130,10 +135,17 @@ $LastReportFile = Join-Path $script:InstallRoot "Logs\last_daily_report.txt"
|
|||||||
$EnableRDGatewayMonitoring = $true
|
$EnableRDGatewayMonitoring = $true
|
||||||
$RDGatewayLogName = "Microsoft-Windows-TerminalServices-Gateway/Operational"
|
$RDGatewayLogName = "Microsoft-Windows-TerminalServices-Gateway/Operational"
|
||||||
$RDGatewayEvents = @(302, 303)
|
$RDGatewayEvents = @(302, 303)
|
||||||
|
# Дубликаты RDG 302 (User+Target+ExternalIP) в окне N с — один alert в SAC/Telegram.
|
||||||
|
$Rdg302NotifyDedupSeconds = 90
|
||||||
# Макс. возраст сохранённого курсора RD Gateway (мин): если файл старше — replay не делаем.
|
# Макс. возраст сохранённого курсора RD Gateway (мин): если файл старше — replay не делаем.
|
||||||
$GatewayEventsLookbackMinutes = 60
|
$GatewayEventsLookbackMinutes = 60
|
||||||
|
# Security 4624/4625/4648: персистентный cursor + replay не старше N мин (медленный boot / поздний старт агента).
|
||||||
|
$SecurityEventsLookbackMinutes = 60
|
||||||
|
# RCM 1149: персистентный cursor + replay (как Security).
|
||||||
|
$Rcm1149EventsLookbackMinutes = 60
|
||||||
|
|
||||||
# RDP Remote Connection Manager (workstations): User authentication succeeded — событие 1149
|
# RDP Remote Connection Manager: User authentication succeeded — событие 1149 (workstation + RDS server)
|
||||||
|
$EnableRcm1149Monitoring = 1
|
||||||
$RcmLogName = "Microsoft-Windows-TerminalServices-RemoteConnectionManager/Operational"
|
$RcmLogName = "Microsoft-Windows-TerminalServices-RemoteConnectionManager/Operational"
|
||||||
$RcmEventId = 1149
|
$RcmEventId = 1149
|
||||||
|
|
||||||
@@ -246,7 +258,9 @@ $SacUrl = ''
|
|||||||
$SacApiKey = ''
|
$SacApiKey = ''
|
||||||
$SacSpoolDir = ''
|
$SacSpoolDir = ''
|
||||||
$SacAgentIdFile = ''
|
$SacAgentIdFile = ''
|
||||||
$SacTimeoutSec = 12
|
$SacTimeoutSec = 45
|
||||||
|
$SacSpoolFlushMaxFiles = 50
|
||||||
|
$SacSpoolMaxAgeHours = 72
|
||||||
$SacTlsSkipVerify = $false
|
$SacTlsSkipVerify = $false
|
||||||
$SacFallbackFailures = 5
|
$SacFallbackFailures = 5
|
||||||
|
|
||||||
@@ -1092,7 +1106,8 @@ function Send-RdpMonitorLifecycleNotification {
|
|||||||
[Parameter(Mandatory = $true)][string]$SacSummary,
|
[Parameter(Mandatory = $true)][string]$SacSummary,
|
||||||
[string]$SacTitle = '',
|
[string]$SacTitle = '',
|
||||||
[string]$SacSeverity = 'info',
|
[string]$SacSeverity = 'info',
|
||||||
[string]$EmailSubject = 'RDP Login Monitor'
|
[string]$EmailSubject = 'RDP Login Monitor',
|
||||||
|
[hashtable]$SacDetailsExtra = @{}
|
||||||
)
|
)
|
||||||
|
|
||||||
$title = if (-not [string]::IsNullOrWhiteSpace($SacTitle)) { $SacTitle } else { "RDP login monitor: $Lifecycle" }
|
$title = if (-not [string]::IsNullOrWhiteSpace($SacTitle)) { $SacTitle } else { "RDP login monitor: $Lifecycle" }
|
||||||
@@ -1101,6 +1116,9 @@ function Send-RdpMonitorLifecycleNotification {
|
|||||||
lifecycle = $Lifecycle
|
lifecycle = $Lifecycle
|
||||||
trigger = $Trigger
|
trigger = $Trigger
|
||||||
}
|
}
|
||||||
|
foreach ($dk in @($SacDetailsExtra.Keys)) {
|
||||||
|
$sacDetails[$dk] = $SacDetailsExtra[$dk]
|
||||||
|
}
|
||||||
if (-not [string]::IsNullOrWhiteSpace($plainBody)) {
|
if (-not [string]::IsNullOrWhiteSpace($plainBody)) {
|
||||||
$sacDetails.notification_body = $plainBody
|
$sacDetails.notification_body = $plainBody
|
||||||
}
|
}
|
||||||
@@ -1122,6 +1140,9 @@ if (-not (Test-Administrator)) {
|
|||||||
Write-Log "Скрипт запущен с правами администратора, версия $ScriptVersion"
|
Write-Log "Скрипт запущен с правами администратора, версия $ScriptVersion"
|
||||||
if ($script:LoginMonitorSettingsLoaded) {
|
if ($script:LoginMonitorSettingsLoaded) {
|
||||||
Write-Log "Настройки: login_monitor.settings.ps1 загружен."
|
Write-Log "Настройки: login_monitor.settings.ps1 загружен."
|
||||||
|
if ($SacTlsSkipVerify) {
|
||||||
|
Write-Log 'CRITICAL: SacTlsSkipVerify=$true — проверка TLS для SAC отключена (риск MITM). Только для краткого отладочного окна в lab.'
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Write-Log "Предупреждение: login_monitor.settings.ps1 не найден — Telegram/SMTP/4740 не настроены (скопируйте login_monitor.settings.example.ps1)."
|
Write-Log "Предупреждение: login_monitor.settings.ps1 не найден — Telegram/SMTP/4740 не настроены (скопируйте login_monitor.settings.example.ps1)."
|
||||||
}
|
}
|
||||||
@@ -1518,6 +1539,122 @@ function Test-RcmLogAvailable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Get-Rcm1149UserDataEventInfoMap {
|
||||||
|
param($Event)
|
||||||
|
|
||||||
|
$map = @{}
|
||||||
|
try {
|
||||||
|
$xml = [xml]$Event.ToXml()
|
||||||
|
$userData = $xml.Event.UserData
|
||||||
|
if ($null -eq $userData) { return $map }
|
||||||
|
|
||||||
|
$eventXml = $userData.EventXML
|
||||||
|
if ($null -eq $eventXml) {
|
||||||
|
foreach ($child in @($userData.ChildNodes)) {
|
||||||
|
if ($null -ne $child -and $child.LocalName -eq 'EventXML') {
|
||||||
|
$eventXml = $child
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($null -eq $eventXml) { return $map }
|
||||||
|
|
||||||
|
foreach ($node in @($eventXml.ChildNodes)) {
|
||||||
|
if ($null -eq $node -or $node.NodeType -ne [System.Xml.XmlNodeType]::Element) { continue }
|
||||||
|
$map[$node.LocalName] = [string]$node.InnerText
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
return $map
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resolve-Rcm1149UsernameFromParts {
|
||||||
|
param(
|
||||||
|
[string]$Param1,
|
||||||
|
[string]$Param2
|
||||||
|
)
|
||||||
|
|
||||||
|
$p1 = if ($null -ne $Param1) { $Param1.Trim() } else { '' }
|
||||||
|
$p2 = if ($null -ne $Param2) { $Param2.Trim() } else { '' }
|
||||||
|
if ([string]::IsNullOrWhiteSpace($p1)) { return $null }
|
||||||
|
if ($p1 -match '[\\@]') { return $p1 }
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($p2)) {
|
||||||
|
return "$p2\$p1"
|
||||||
|
}
|
||||||
|
return $p1
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-Rcm1149UsernameFromQwinsta {
|
||||||
|
<#
|
||||||
|
На части ПК (наблюдалось на Win10 Pro) RCM 1149 пишет пустые Param1/Param2,
|
||||||
|
хотя сеанс уже есть в qwinsta. Берём единственную RDP-сессию (rdp-tcp#N / Disc).
|
||||||
|
Состояния локализованы (Активно/Listen/…) — не завязываемся на английский STATE.
|
||||||
|
#>
|
||||||
|
$qwExe = Join-Path $env:SystemRoot 'System32\qwinsta.exe'
|
||||||
|
if (-not (Test-Path -LiteralPath $qwExe)) { return $null }
|
||||||
|
|
||||||
|
$prevEa = $ErrorActionPreference
|
||||||
|
$rawLines = @()
|
||||||
|
try {
|
||||||
|
$ErrorActionPreference = 'SilentlyContinue'
|
||||||
|
$rawLines = @(& $qwExe 2>&1 | ForEach-Object { [string]$_ })
|
||||||
|
} catch {
|
||||||
|
return $null
|
||||||
|
} finally {
|
||||||
|
$ErrorActionPreference = $prevEa
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidates = [System.Collections.Generic.List[string]]::new()
|
||||||
|
foreach ($raw in $rawLines) {
|
||||||
|
$text = if ($null -ne $raw) { $raw.Trim() } else { '' }
|
||||||
|
if ([string]::IsNullOrWhiteSpace($text)) { continue }
|
||||||
|
if ($text -match '(?i)^SESSION') { continue }
|
||||||
|
if ($text -match '^-+$') { continue }
|
||||||
|
|
||||||
|
$parts = @($text -split '\s+' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
|
||||||
|
if ($parts.Count -lt 2) { continue }
|
||||||
|
|
||||||
|
$idIdx = -1
|
||||||
|
$sid = -1
|
||||||
|
for ($i = 0; $i -lt $parts.Count; $i++) {
|
||||||
|
$token = $parts[$i].TrimStart('>')
|
||||||
|
if ($token -match '^\d+$') {
|
||||||
|
$idIdx = $i
|
||||||
|
$sid = [int]$token
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($idIdx -lt 1) { continue }
|
||||||
|
# Listener «rdp-tcp» / ID 65536 — не пользовательская сессия.
|
||||||
|
if ($sid -eq 65536) { continue }
|
||||||
|
|
||||||
|
$before = @($parts[0..($idIdx - 1)])
|
||||||
|
if ($before.Count -eq 1) {
|
||||||
|
$sessionName = ''
|
||||||
|
$userName = $before[0].TrimStart('>')
|
||||||
|
} else {
|
||||||
|
$sessionName = $before[0].TrimStart('>')
|
||||||
|
$userName = $before[1]
|
||||||
|
}
|
||||||
|
if ([string]::IsNullOrWhiteSpace($userName)) { continue }
|
||||||
|
if ($userName -match '(?i)^(services|console|rdp-tcp)$') { continue }
|
||||||
|
if ($sessionName -match '(?i)^console$') { continue }
|
||||||
|
if ($sessionName -match '(?i)^rdp-tcp$' -and $sessionName -notmatch '#') { continue }
|
||||||
|
|
||||||
|
$isRdpNamed = $sessionName -match '(?i)^rdp-tcp#\d+'
|
||||||
|
$isDiscBare = [string]::IsNullOrWhiteSpace($sessionName)
|
||||||
|
if (-not ($isRdpNamed -or $isDiscBare)) { continue }
|
||||||
|
|
||||||
|
if (-not $candidates.Contains($userName)) {
|
||||||
|
[void]$candidates.Add($userName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($candidates.Count -eq 1) {
|
||||||
|
return $candidates[0]
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
function Get-Rcm1149EventInfo {
|
function Get-Rcm1149EventInfo {
|
||||||
param($Event)
|
param($Event)
|
||||||
$eventData = @{
|
$eventData = @{
|
||||||
@@ -1527,25 +1664,49 @@ function Get-Rcm1149EventInfo {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
$map = Get-EventDataMap -Event $Event
|
$map = Get-EventDataMap -Event $Event
|
||||||
|
$userDataMap = Get-Rcm1149UserDataEventInfoMap -Event $Event
|
||||||
$user = Get-FirstNonEmptyMapValue -DataMap $map -Keys @(
|
$user = Get-FirstNonEmptyMapValue -DataMap $map -Keys @(
|
||||||
"TargetUser","User","Domain User","Param1","AccountName","ConnectionUser","SubjectUserName"
|
"TargetUser","User","Domain User","Param1","AccountName","ConnectionUser","SubjectUserName"
|
||||||
)
|
)
|
||||||
$ip = Get-FirstNonEmptyMapValue -DataMap $map -Keys @(
|
$ip = Get-FirstNonEmptyMapValue -DataMap $map -Keys @(
|
||||||
"ClientIP","Client Address","IpAddress","Ip","Param3","Address","CallingStationId"
|
"ClientIP","Client Address","IpAddress","Ip","Param3","Address","CallingStationId"
|
||||||
)
|
)
|
||||||
if (-not [string]::IsNullOrWhiteSpace($user)) { $eventData.Username = [string]$user }
|
if ([string]::IsNullOrWhiteSpace($user)) {
|
||||||
if (-not [string]::IsNullOrWhiteSpace($ip)) { $eventData.ClientIP = [string]$ip }
|
$user = Resolve-Rcm1149UsernameFromParts -Param1 $userDataMap['Param1'] -Param2 $userDataMap['Param2']
|
||||||
|
|
||||||
if ($eventData.Username -eq '-' -and $Event.Properties.Count -gt 0) {
|
|
||||||
$eventData.Username = [string]$Event.Properties[0].Value
|
|
||||||
}
|
}
|
||||||
if ($eventData.ClientIP -eq '-') {
|
if ([string]::IsNullOrWhiteSpace($ip)) {
|
||||||
if ($Event.Properties.Count -gt 2) {
|
$ip = Get-FirstNonEmptyMapValue -DataMap $userDataMap -Keys @('Param3', 'Param2', 'Param1')
|
||||||
$eventData.ClientIP = [string]$Event.Properties[2].Value
|
}
|
||||||
} elseif ($Event.Properties.Count -gt 1) {
|
if (-not [string]::IsNullOrWhiteSpace($user)) { $eventData.Username = [string]$user }
|
||||||
$eventData.ClientIP = [string]$Event.Properties[1].Value
|
if (-not [string]::IsNullOrWhiteSpace($ip)) {
|
||||||
|
$eventData.ClientIP = Get-RdpMonitorNormalizedClientIp -Value ([string]$ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($eventData.Username -eq '-' -or [string]::IsNullOrWhiteSpace($eventData.Username)) -and $Event.Properties.Count -gt 0) {
|
||||||
|
$p0 = [string]$Event.Properties[0].Value
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($p0)) {
|
||||||
|
$eventData.Username = $p0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if ($eventData.ClientIP -eq '-' -or [string]::IsNullOrWhiteSpace($eventData.ClientIP)) {
|
||||||
|
if ($Event.Properties.Count -gt 2) {
|
||||||
|
$p2 = [string]$Event.Properties[2].Value
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($p2)) {
|
||||||
|
$eventData.ClientIP = Get-RdpMonitorNormalizedClientIp -Value $p2
|
||||||
|
}
|
||||||
|
} elseif ($Event.Properties.Count -gt 1) {
|
||||||
|
$p1 = [string]$Event.Properties[1].Value
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($p1)) {
|
||||||
|
$eventData.ClientIP = Get-RdpMonitorNormalizedClientIp -Value $p1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ([string]::IsNullOrWhiteSpace($eventData.Username) -or $eventData.Username -eq '-') {
|
||||||
|
$eventData.Username = '-'
|
||||||
|
}
|
||||||
|
if ([string]::IsNullOrWhiteSpace($eventData.ClientIP) -or $eventData.ClientIP -eq '-') {
|
||||||
|
$eventData.ClientIP = '-'
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
Write-Log "Ошибка разбора события RCM 1149: $($_.Exception.Message)"
|
Write-Log "Ошибка разбора события RCM 1149: $($_.Exception.Message)"
|
||||||
}
|
}
|
||||||
@@ -2444,6 +2605,110 @@ function Send-RdpMonitorSacHeartbeat {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Get-RdpMonitorSystemEventShortLabel {
|
||||||
|
param([int]$Id)
|
||||||
|
switch ($Id) {
|
||||||
|
41 { return 'Kernel-Power: unexpected reboot' }
|
||||||
|
1074 { return 'User32: planned shutdown/restart' }
|
||||||
|
6005 { return 'EventLog: service started' }
|
||||||
|
6008 { return 'EventLog: previous shutdown unexpected' }
|
||||||
|
6009 { return 'EventLog: OS version at startup' }
|
||||||
|
default { return "System ID $Id" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-RdpMonitorStartupCause {
|
||||||
|
param([int]$WindowMinutes = 5)
|
||||||
|
|
||||||
|
if ($WindowMinutes -lt 1) { $WindowMinutes = 5 }
|
||||||
|
$now = Get-Date
|
||||||
|
$since = $now.AddMinutes(-$WindowMinutes)
|
||||||
|
$bootTime = $null
|
||||||
|
$uptimeMin = $null
|
||||||
|
try {
|
||||||
|
$os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop
|
||||||
|
$bootTime = $os.LastBootUpTime
|
||||||
|
if ($null -ne $bootTime) {
|
||||||
|
$uptimeMin = ($now - $bootTime).TotalMinutes
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Log "WARN: StartupCause: Win32_OperatingSystem недоступен: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
|
||||||
|
$rebootEventIds = @(41, 1074, 6005, 6008, 6009)
|
||||||
|
$events = @()
|
||||||
|
$eventQueryStart = $since
|
||||||
|
# После ребута монитор может стартовать позже окна (5 мин): тогда берём System с LastBootUpTime (до 60 мин).
|
||||||
|
if ($null -ne $bootTime -and $null -ne $uptimeMin -and $uptimeMin -le 60 -and $bootTime -lt $eventQueryStart) {
|
||||||
|
$eventQueryStart = $bootTime
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$events = @(Get-WinEvent -FilterHashtable @{
|
||||||
|
LogName = 'System'
|
||||||
|
Id = $rebootEventIds
|
||||||
|
StartTime = $eventQueryStart
|
||||||
|
} -MaxEvents 15 -ErrorAction Stop | Sort-Object TimeCreated)
|
||||||
|
} catch {
|
||||||
|
if ($_.Exception -and $_.Exception.Message -notmatch 'No events were found|NoMatchingEventsFound') {
|
||||||
|
Write-Log "WARN: StartupCause: чтение журнала System: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$bootRecent = ($null -ne $uptimeMin -and $uptimeMin -le $WindowMinutes)
|
||||||
|
$hasRebootEvents = ($events.Count -gt 0)
|
||||||
|
$bootEventsRecent = ($hasRebootEvents -and $null -ne $uptimeMin -and $uptimeMin -le 60)
|
||||||
|
|
||||||
|
if ($bootRecent -or $bootEventsRecent) {
|
||||||
|
return [pscustomobject]@{
|
||||||
|
Cause = 'os_reboot'
|
||||||
|
WindowMinutes = $WindowMinutes
|
||||||
|
BootTime = $bootTime
|
||||||
|
UptimeMinutes = if ($null -ne $uptimeMin) { [math]::Round($uptimeMin, 1) } else { $null }
|
||||||
|
SystemEvents = @($events | ForEach-Object {
|
||||||
|
[pscustomobject]@{
|
||||||
|
TimeCreated = $_.TimeCreated
|
||||||
|
Id = $_.Id
|
||||||
|
ProviderName = $_.ProviderName
|
||||||
|
MessageShort = (Get-RdpMonitorSystemEventShortLabel -Id $_.Id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [pscustomobject]@{
|
||||||
|
Cause = 'process_start'
|
||||||
|
WindowMinutes = $WindowMinutes
|
||||||
|
BootTime = $bootTime
|
||||||
|
UptimeMinutes = if ($null -ne $uptimeMin) { [math]::Round($uptimeMin, 1) } else { $null }
|
||||||
|
SystemEvents = @()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Format-RdpMonitorStartupCauseLogLine {
|
||||||
|
param([Parameter(Mandatory = $true)]$StartupCause)
|
||||||
|
|
||||||
|
if ($StartupCause.Cause -eq 'os_reboot') {
|
||||||
|
$parts = [System.Collections.Generic.List[string]]::new()
|
||||||
|
[void]$parts.Add('Старт монитора после перезагрузки ОС')
|
||||||
|
if ($null -ne $StartupCause.UptimeMinutes) {
|
||||||
|
[void]$parts.Add("uptime $($StartupCause.UptimeMinutes) мин")
|
||||||
|
}
|
||||||
|
if ($StartupCause.BootTime) {
|
||||||
|
[void]$parts.Add("LastBootUpTime=$($StartupCause.BootTime.ToString('dd.MM.yyyy HH:mm:ss'))")
|
||||||
|
}
|
||||||
|
if ($StartupCause.SystemEvents.Count -gt 0) {
|
||||||
|
$evSumm = ($StartupCause.SystemEvents | ForEach-Object {
|
||||||
|
"$($_.TimeCreated.ToString('dd.MM.yyyy HH:mm:ss')) ID$($_.Id) ($($_.MessageShort))"
|
||||||
|
}) -join '; '
|
||||||
|
[void]$parts.Add("System: $evSumm")
|
||||||
|
}
|
||||||
|
return ($parts -join '; ') + '.'
|
||||||
|
}
|
||||||
|
|
||||||
|
$uptimeStr = if ($null -ne $StartupCause.UptimeMinutes) { "$($StartupCause.UptimeMinutes) мин" } else { '?' }
|
||||||
|
return "Старт монитора: перезагрузка ОС за последние $($StartupCause.WindowMinutes) мин не обнаружена (uptime $uptimeStr) — перезапуск процесса или задачи планировщика."
|
||||||
|
}
|
||||||
|
|
||||||
function Send-Heartbeat {
|
function Send-Heartbeat {
|
||||||
param([switch]$IsStartup = $false)
|
param([switch]$IsStartup = $false)
|
||||||
|
|
||||||
@@ -2489,6 +2754,14 @@ function Send-Heartbeat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($IsStartup) {
|
if ($IsStartup) {
|
||||||
|
$windowMin = 5
|
||||||
|
if (Get-Variable -Name StartupRebootDetectMinutes -Scope Script -ErrorAction SilentlyContinue) {
|
||||||
|
$w = (Get-Variable -Name StartupRebootDetectMinutes -Scope Script -ValueOnly)
|
||||||
|
if ($null -ne $w -and [int]$w -gt 0) { $windowMin = [int]$w }
|
||||||
|
}
|
||||||
|
$startupCause = Get-RdpMonitorStartupCause -WindowMinutes $windowMin
|
||||||
|
Write-Log (Format-RdpMonitorStartupCauseLogLine -StartupCause $startupCause)
|
||||||
|
|
||||||
$message = "<b>✅ Мониторинг логинов ЗАПУЩЕН</b>`r`n"
|
$message = "<b>✅ Мониторинг логинов ЗАПУЩЕН</b>`r`n"
|
||||||
$message += "🏷️ Версия скрипта: $(ConvertTo-TelegramHtml $ScriptVersion)"
|
$message += "🏷️ Версия скрипта: $(ConvertTo-TelegramHtml $ScriptVersion)"
|
||||||
$upd = Get-DeployUpdateMarker
|
$upd = Get-DeployUpdateMarker
|
||||||
@@ -2497,17 +2770,25 @@ function Send-Heartbeat {
|
|||||||
$message += " (обновлён $(ConvertTo-TelegramHtml $upd.UpdatedAt))"
|
$message += " (обновлён $(ConvertTo-TelegramHtml $upd.UpdatedAt))"
|
||||||
$lifecycleTrigger = 'deploy_recycle'
|
$lifecycleTrigger = 'deploy_recycle'
|
||||||
Set-DeployUpdateMarkerPendingOff -Marker $upd
|
Set-DeployUpdateMarkerPendingOff -Marker $upd
|
||||||
|
} elseif ($startupCause.Cause -eq 'os_reboot') {
|
||||||
|
$lifecycleTrigger = 'os_reboot'
|
||||||
|
} elseif ($startupCause.Cause -eq 'process_start') {
|
||||||
|
$lifecycleTrigger = 'process_start'
|
||||||
}
|
}
|
||||||
$message += "`r`n"
|
$message += "`r`n"
|
||||||
$message += "🖥️ Сервер: $hHost`r`n"
|
$message += "🖥️ Сервер: $hHost`r`n"
|
||||||
$message += "🕐 Время запуска: $timestamp"
|
$message += "🕐 Время запуска: $timestamp"
|
||||||
|
if ($startupCause.Cause -eq 'os_reboot') {
|
||||||
|
$causePlain = Format-RdpMonitorStartupCauseLogLine -StartupCause $startupCause
|
||||||
|
$message += "`r`n🔄 $(ConvertTo-TelegramHtml $causePlain)"
|
||||||
|
}
|
||||||
if ($script:OsInstallKindLabel) {
|
if ($script:OsInstallKindLabel) {
|
||||||
$message += "`r`n💻 <b>Тип установки:</b> $(ConvertTo-TelegramHtml $script:OsInstallKindLabel)"
|
$message += "`r`n💻 <b>Тип установки:</b> $(ConvertTo-TelegramHtml $script:OsInstallKindLabel)"
|
||||||
}
|
}
|
||||||
if ($script:IsWorkstation) {
|
if ($script:IsWorkstation) {
|
||||||
$message += "`r`n📌 <b>Режим:</b> рабочая станция — Security 4624/4625 только LogonType 10 (RDP); при наличии журнала — также 1149 (Remote Connection Manager)."
|
$message += "`r`n📌 <b>Режим:</b> рабочая станция — Security 4624/4625 только LogonType 10 (RDP); при наличии журнала — также 1149 (Remote Connection Manager)."
|
||||||
} else {
|
} else {
|
||||||
$message += "`r`n📌 <b>Режим:</b> сервер — Security 4624/4625, типы входа 2, 3, 10."
|
$message += "`r`n📌 <b>Режим:</b> сервер — Security 4624/4625, типы входа 2, 3, 10; при журнале RCM — также 1149 (RDS без аудита 4624)."
|
||||||
}
|
}
|
||||||
$ignoreEntries = @(Get-RdpMonitorIgnoreListEntries)
|
$ignoreEntries = @(Get-RdpMonitorIgnoreListEntries)
|
||||||
if ($ignoreEntries.Count -gt 0) {
|
if ($ignoreEntries.Count -gt 0) {
|
||||||
@@ -2551,10 +2832,28 @@ function Send-Heartbeat {
|
|||||||
$message += " IP из IIS не заданы (<code>ExchangeIisLogPath</code> пуст)."
|
$message += " IP из IIS не заданы (<code>ExchangeIisLogPath</code> пуст)."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
$sacStartupExtra = @{
|
||||||
|
startup_cause = $startupCause.Cause
|
||||||
|
}
|
||||||
|
if ($null -ne $startupCause.UptimeMinutes) {
|
||||||
|
$sacStartupExtra.uptime_minutes = $startupCause.UptimeMinutes
|
||||||
|
}
|
||||||
|
if ($startupCause.BootTime) {
|
||||||
|
$sacStartupExtra.last_boot_up_time = $startupCause.BootTime.ToString('o')
|
||||||
|
}
|
||||||
|
if ($startupCause.SystemEvents.Count -gt 0) {
|
||||||
|
$sacStartupExtra.system_event_ids = @($startupCause.SystemEvents | ForEach-Object { $_.Id }) -join ','
|
||||||
|
}
|
||||||
|
|
||||||
Send-RdpMonitorLifecycleNotification -Lifecycle 'started' -Trigger $lifecycleTrigger `
|
Send-RdpMonitorLifecycleNotification -Lifecycle 'started' -Trigger $lifecycleTrigger `
|
||||||
-TelegramHtmlMessage $message -EmailSubject 'RDP Login Monitor: запуск' `
|
-TelegramHtmlMessage $message -EmailSubject 'RDP Login Monitor: запуск' `
|
||||||
-SacTitle 'RDP login monitor started' `
|
-SacTitle 'RDP login monitor started' `
|
||||||
-SacSummary "Мониторинг запущен на $(Get-MonitorServerLabelWithIp), версия $ScriptVersion"
|
-SacSummary $(if ($startupCause.Cause -eq 'os_reboot') {
|
||||||
|
"Мониторинг запущен после перезагрузки ОС на $(Get-MonitorServerLabelWithIp), версия $ScriptVersion"
|
||||||
|
} else {
|
||||||
|
"Мониторинг запущен на $(Get-MonitorServerLabelWithIp), версия $ScriptVersion"
|
||||||
|
}) `
|
||||||
|
-SacDetailsExtra $sacStartupExtra
|
||||||
Write-Log "Отправлено уведомление о запуске скрипта (каналы: $notifyChain)"
|
Write-Log "Отправлено уведомление о запуске скрипта (каналы: $notifyChain)"
|
||||||
Send-RdpMonitorSacHeartbeat -Timestamp $timestamp
|
Send-RdpMonitorSacHeartbeat -Timestamp $timestamp
|
||||||
} else {
|
} else {
|
||||||
@@ -2880,10 +3179,13 @@ function Parse-RdpMonitorIgnoreListLine {
|
|||||||
if ($line[0] -eq '#' -or $line[0] -eq ';') { return $null }
|
if ($line[0] -eq '#' -or $line[0] -eq ';') { return $null }
|
||||||
if ($line.StartsWith([char]0xFEFF)) { $line = $line.TrimStart([char]0xFEFF) }
|
if ($line.StartsWith([char]0xFEFF)) { $line = $line.TrimStart([char]0xFEFF) }
|
||||||
|
|
||||||
$scopes = @('4624', '4625')
|
$scopes = @('4624', '4625', '4634', '4647')
|
||||||
if ($line -match '^(?i)(4740|lockout|блокир)\s*:\s*(.+)$') {
|
if ($line -match '^(?i)(4740|lockout|блокир)\s*:\s*(.+)$') {
|
||||||
$scopes = @('4740')
|
$scopes = @('4740')
|
||||||
$line = $Matches[2].Trim()
|
$line = $Matches[2].Trim()
|
||||||
|
} elseif ($line -match '^(?i)(logoff|logout|4634|4647)\s*:\s*(.+)$') {
|
||||||
|
$scopes = @('4634', '4647')
|
||||||
|
$line = $Matches[2].Trim()
|
||||||
} elseif ($line -match '^(?i)(shadow|20506)\s*:\s*(.+)$') {
|
} elseif ($line -match '^(?i)(shadow|20506)\s*:\s*(.+)$') {
|
||||||
$scopes = @('20506', '20507', '20510')
|
$scopes = @('20506', '20507', '20510')
|
||||||
$line = $Matches[2].Trim()
|
$line = $Matches[2].Trim()
|
||||||
@@ -2894,7 +3196,7 @@ function Parse-RdpMonitorIgnoreListLine {
|
|||||||
$scopes = @('5140')
|
$scopes = @('5140')
|
||||||
$line = $Matches[2].Trim()
|
$line = $Matches[2].Trim()
|
||||||
} elseif ($line -match '^(?i)(all|\*)\s*:\s*(.+)$') {
|
} elseif ($line -match '^(?i)(all|\*)\s*:\s*(.+)$') {
|
||||||
$scopes = @('4624', '4625', '4740', '20506', '20507', '20510', 'winrm', '5140')
|
$scopes = @('4624', '4625', '4634', '4647', '4740', '20506', '20507', '20510', 'winrm', '5140')
|
||||||
$line = $Matches[2].Trim()
|
$line = $Matches[2].Trim()
|
||||||
}
|
}
|
||||||
if ([string]::IsNullOrWhiteSpace($line)) { return $null }
|
if ([string]::IsNullOrWhiteSpace($line)) { return $null }
|
||||||
@@ -3079,8 +3381,10 @@ function Should-IgnoreEvent {
|
|||||||
if ($Username -like $p) { return $true }
|
if ($Username -like $p) { return $true }
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($ComputerName -eq "Authz" -or $ComputerName -like "Authz*") { return $true }
|
if ($EventID -ne 1149 -and $EventID -notin 4634, 4647) {
|
||||||
if ($ComputerName -eq "NtLmSsp" -or $ComputerName -like "NtLmSsp*" -or $ComputerName -like "*NtLmSsp*") { return $true }
|
if ($ComputerName -eq "Authz" -or $ComputerName -like "Authz*") { return $true }
|
||||||
|
if ($ComputerName -eq "NtLmSsp" -or $ComputerName -like "NtLmSsp*" -or $ComputerName -like "*NtLmSsp*") { return $true }
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($lp in $ExcludedLogonProcesses) {
|
foreach ($lp in $ExcludedLogonProcesses) {
|
||||||
if ($ProcessName -like "*$lp*") { return $true }
|
if ($ProcessName -like "*$lp*") { return $true }
|
||||||
@@ -3090,13 +3394,14 @@ function Should-IgnoreEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($SourceIP -eq "127.0.0.1" -or $SourceIP -like "fe80:*") { return $true }
|
if ($SourceIP -eq "127.0.0.1" -or $SourceIP -like "fe80:*") { return $true }
|
||||||
if ($ComputerName -eq "-" -or $ComputerName -eq "N/A") { return $true }
|
# RCM 1149 и logoff 4634/4647 часто без WorkstationName в EventData.
|
||||||
|
if ($EventID -notin 1149, 4634, 4647 -and ($ComputerName -eq "-" -or $ComputerName -eq "N/A")) { return $true }
|
||||||
|
|
||||||
foreach ($pattern in $ExcludedComputerPatterns) {
|
foreach ($pattern in $ExcludedComputerPatterns) {
|
||||||
if ($ComputerName -like $pattern) { return $true }
|
if ($ComputerName -like $pattern) { return $true }
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($EventID -in 4624, 4625) {
|
if ($EventID -in 4624, 4625, 4634, 4647, 1149) {
|
||||||
if (Test-RdpMonitorIgnoreListMatch -EventId ([string]$EventID) -Username $Username `
|
if (Test-RdpMonitorIgnoreListMatch -EventId ([string]$EventID) -Username $Username `
|
||||||
-ComputerName $ComputerName -SourceIP $SourceIP) {
|
-ComputerName $ComputerName -SourceIP $SourceIP) {
|
||||||
return $true
|
return $true
|
||||||
@@ -3116,6 +3421,7 @@ function Get-LoginEventInfo {
|
|||||||
SourceIP = "-"
|
SourceIP = "-"
|
||||||
ProcessName = "-"
|
ProcessName = "-"
|
||||||
LogonType = 0
|
LogonType = 0
|
||||||
|
SessionId = $null
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -3133,6 +3439,17 @@ function Get-LoginEventInfo {
|
|||||||
$eventData.ProcessName = Get-FirstNonEmptyMapValue -DataMap $map -Keys @(
|
$eventData.ProcessName = Get-FirstNonEmptyMapValue -DataMap $map -Keys @(
|
||||||
"SubStatus","Status","FailureReason","FailureReasonCode"
|
"SubStatus","Status","FailureReason","FailureReasonCode"
|
||||||
)
|
)
|
||||||
|
} elseif ($Event.Id -in 4634, 4647) {
|
||||||
|
if ($Event.Id -eq 4647) {
|
||||||
|
$subjectUser = Get-FirstNonEmptyMapValue -DataMap $map -Keys @("SubjectUserName","AccountName")
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($subjectUser)) {
|
||||||
|
$eventData.Username = $subjectUser
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$sidRaw = Get-FirstNonEmptyMapValue -DataMap $map -Keys @("TargetLogonId","SubjectLogonId","LogonId")
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($sidRaw)) {
|
||||||
|
$eventData.SessionId = [string]$sidRaw.Trim()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
Write-Log "Ошибка при извлечении данных события: $($_.Exception.Message)"
|
Write-Log "Ошибка при извлечении данных события: $($_.Exception.Message)"
|
||||||
@@ -3205,6 +3522,8 @@ function Format-LoginEvent {
|
|||||||
$message = "<b>"
|
$message = "<b>"
|
||||||
if ($EventID -eq 4624) { $message += "✅ УСПЕШНЫЙ ВХОД" }
|
if ($EventID -eq 4624) { $message += "✅ УСПЕШНЫЙ ВХОД" }
|
||||||
elseif ($EventID -eq 4625) { $message += "❌ НЕУДАЧНАЯ ПОПЫТКА" }
|
elseif ($EventID -eq 4625) { $message += "❌ НЕУДАЧНАЯ ПОПЫТКА" }
|
||||||
|
elseif ($EventID -eq 4634) { $message += "🚪 ВЫХОД ИЗ СЕССИИ" }
|
||||||
|
elseif ($EventID -eq 4647) { $message += "🚪 ВЫХОД ПОЛЬЗОВАТЕЛЯ" }
|
||||||
else { $message += "⚠️ СОБЫТИЕ" }
|
else { $message += "⚠️ СОБЫТИЕ" }
|
||||||
$message += "</b>`r`n"
|
$message += "</b>`r`n"
|
||||||
|
|
||||||
@@ -3225,6 +3544,7 @@ function Format-LoginEvent {
|
|||||||
|
|
||||||
$script:FailedLogonBuckets = @{}
|
$script:FailedLogonBuckets = @{}
|
||||||
$script:LoginSuccessNotifyDedup = @{}
|
$script:LoginSuccessNotifyDedup = @{}
|
||||||
|
$script:LogoffNotifyDedup = @{}
|
||||||
|
|
||||||
function Get-RdpLoginNotifyDedupHostPart {
|
function Get-RdpLoginNotifyDedupHostPart {
|
||||||
param([string]$SecurityLogComputerName)
|
param([string]$SecurityLogComputerName)
|
||||||
@@ -3283,6 +3603,33 @@ function Test-RdpLoginSuccessNotifyDedupAllow {
|
|||||||
return $true
|
return $true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Get-RdpLogoffNotifyDedupKey {
|
||||||
|
param(
|
||||||
|
[string]$SecurityLogComputerName,
|
||||||
|
[string]$Username,
|
||||||
|
[int]$LogonType
|
||||||
|
)
|
||||||
|
$hostPart = Get-RdpLoginNotifyDedupHostPart -SecurityLogComputerName $SecurityLogComputerName
|
||||||
|
$userPart = Get-RdpLoginNotifyDedupUsernamePart -Username $Username
|
||||||
|
return "$hostPart|logoff|$userPart|$LogonType"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-RdpLogoffNotifyDedupAllow {
|
||||||
|
param([string]$DedupKey)
|
||||||
|
|
||||||
|
if ($LoginSuccessNotifyDedupSeconds -le 0) { return $true }
|
||||||
|
$nowUtc = (Get-Date).ToUniversalTime()
|
||||||
|
if ($script:LogoffNotifyDedup.ContainsKey($DedupKey)) {
|
||||||
|
$lastUtc = $script:LogoffNotifyDedup[$DedupKey]
|
||||||
|
$delta = ($nowUtc - $lastUtc).TotalSeconds
|
||||||
|
if ($delta -ge 0 -and $delta -lt $LoginSuccessNotifyDedupSeconds) {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$script:LogoffNotifyDedup[$DedupKey] = $nowUtc
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
function Get-FailedLogonSourceKeyPart {
|
function Get-FailedLogonSourceKeyPart {
|
||||||
param(
|
param(
|
||||||
[string]$SourceIP,
|
[string]$SourceIP,
|
||||||
@@ -3563,6 +3910,96 @@ function Set-RdpGatewayPollCursor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Get-RdpSecurityPollCursor {
|
||||||
|
$now = Get-Date
|
||||||
|
$fresh = $now.AddSeconds(-10)
|
||||||
|
$maxAgeMin = [math]::Max(1, [int]$SecurityEventsLookbackMinutes)
|
||||||
|
$lookbackFloor = $now.AddMinutes(-1 * $maxAgeMin)
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $SecurityPollCursorFile)) {
|
||||||
|
Write-Log "Security: cursor отсутствует — replay с $($lookbackFloor.ToString('dd.MM.yyyy HH:mm:ss')) (lookback ${maxAgeMin} мин)."
|
||||||
|
return $lookbackFloor
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$raw = (Get-Content -LiteralPath $SecurityPollCursorFile -TotalCount 1 -ErrorAction Stop).Trim()
|
||||||
|
if ([string]::IsNullOrWhiteSpace($raw)) {
|
||||||
|
Write-Log "Security: cursor пуст — replay с $($lookbackFloor.ToString('dd.MM.yyyy HH:mm:ss')) (lookback ${maxAgeMin} мин)."
|
||||||
|
return $lookbackFloor
|
||||||
|
}
|
||||||
|
$parsed = [datetime]::Parse($raw, [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::RoundtripKind)
|
||||||
|
if ($parsed.Kind -eq [DateTimeKind]::Utc) {
|
||||||
|
$parsed = $parsed.ToLocalTime()
|
||||||
|
}
|
||||||
|
if ($parsed -lt $lookbackFloor) {
|
||||||
|
Write-Log "Security: cursor старше $maxAgeMin мин — replay с $($lookbackFloor.ToString('dd.MM.yyyy HH:mm:ss'))."
|
||||||
|
return $lookbackFloor
|
||||||
|
}
|
||||||
|
Write-Log "Security: cursor=$($parsed.ToString('dd.MM.yyyy HH:mm:ss')) (lookback cap ${maxAgeMin} мин)."
|
||||||
|
return $parsed
|
||||||
|
} catch {
|
||||||
|
Write-Log "Security: не удалось прочитать cursor ($SecurityPollCursorFile) — replay с $($lookbackFloor.ToString('dd.MM.yyyy HH:mm:ss'))."
|
||||||
|
return $lookbackFloor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Set-RdpSecurityPollCursor {
|
||||||
|
param([Parameter(Mandatory = $true)][datetime]$Cursor)
|
||||||
|
try {
|
||||||
|
$dir = Split-Path -Parent $SecurityPollCursorFile
|
||||||
|
if (-not (Test-Path -LiteralPath $dir)) {
|
||||||
|
New-Item -ItemType Directory -Path $dir -Force | Out-Null
|
||||||
|
}
|
||||||
|
Write-TextFileUtf8Bom -Path $SecurityPollCursorFile -Text ($Cursor.ToString('o'))
|
||||||
|
} catch {
|
||||||
|
Write-Log "WARN: Security cursor save failed: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-RdpRcm1149PollCursor {
|
||||||
|
$now = Get-Date
|
||||||
|
$fresh = $now.AddSeconds(-10)
|
||||||
|
$maxAgeMin = [math]::Max(1, [int]$Rcm1149EventsLookbackMinutes)
|
||||||
|
$lookbackFloor = $now.AddMinutes(-1 * $maxAgeMin)
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $Rcm1149PollCursorFile)) {
|
||||||
|
Write-Log "RCM 1149: cursor отсутствует — replay с $($lookbackFloor.ToString('dd.MM.yyyy HH:mm:ss')) (lookback ${maxAgeMin} мин)."
|
||||||
|
return $lookbackFloor
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$raw = (Get-Content -LiteralPath $Rcm1149PollCursorFile -TotalCount 1 -ErrorAction Stop).Trim()
|
||||||
|
if ([string]::IsNullOrWhiteSpace($raw)) {
|
||||||
|
Write-Log "RCM 1149: cursor пуст — replay с $($lookbackFloor.ToString('dd.MM.yyyy HH:mm:ss')) (lookback ${maxAgeMin} мин)."
|
||||||
|
return $lookbackFloor
|
||||||
|
}
|
||||||
|
$parsed = [datetime]::Parse($raw, [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::RoundtripKind)
|
||||||
|
if ($parsed.Kind -eq [DateTimeKind]::Utc) {
|
||||||
|
$parsed = $parsed.ToLocalTime()
|
||||||
|
}
|
||||||
|
if ($parsed -lt $lookbackFloor) {
|
||||||
|
Write-Log "RCM 1149: cursor старше $maxAgeMin мин — replay с $($lookbackFloor.ToString('dd.MM.yyyy HH:mm:ss'))."
|
||||||
|
return $lookbackFloor
|
||||||
|
}
|
||||||
|
Write-Log "RCM 1149: cursor=$($parsed.ToString('dd.MM.yyyy HH:mm:ss')) (lookback cap ${maxAgeMin} мин)."
|
||||||
|
return $parsed
|
||||||
|
} catch {
|
||||||
|
Write-Log "RCM 1149: не удалось прочитать cursor ($Rcm1149PollCursorFile) — replay с $($lookbackFloor.ToString('dd.MM.yyyy HH:mm:ss'))."
|
||||||
|
return $lookbackFloor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Set-RdpRcm1149PollCursor {
|
||||||
|
param([Parameter(Mandatory = $true)][datetime]$Cursor)
|
||||||
|
try {
|
||||||
|
$dir = Split-Path -Parent $Rcm1149PollCursorFile
|
||||||
|
if (-not (Test-Path -LiteralPath $dir)) {
|
||||||
|
New-Item -ItemType Directory -Path $dir -Force | Out-Null
|
||||||
|
}
|
||||||
|
Write-TextFileUtf8Bom -Path $Rcm1149PollCursorFile -Text ($Cursor.ToString('o'))
|
||||||
|
} catch {
|
||||||
|
Write-Log "WARN: RCM 1149 cursor save failed: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function Test-RdgGatewayBenignErrorCode {
|
function Test-RdgGatewayBenignErrorCode {
|
||||||
param([string]$ErrorCode)
|
param([string]$ErrorCode)
|
||||||
if ([string]::IsNullOrWhiteSpace($ErrorCode)) { return $true }
|
if ([string]::IsNullOrWhiteSpace($ErrorCode)) { return $true }
|
||||||
@@ -3573,6 +4010,20 @@ function Test-RdgGatewayBenignErrorCode {
|
|||||||
return $false
|
return $false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Get-Rdg302NotifyDedupKey {
|
||||||
|
param(
|
||||||
|
[string]$Username,
|
||||||
|
[string]$InternalIP,
|
||||||
|
[string]$ExternalIP
|
||||||
|
)
|
||||||
|
$userPart = Get-RdpLoginNotifyDedupUsernamePart -Username $Username
|
||||||
|
$target = if ($InternalIP) { [string]$InternalIP.Trim() } else { '-' }
|
||||||
|
if ([string]::IsNullOrWhiteSpace($target)) { $target = '-' }
|
||||||
|
$ext = if ($ExternalIP) { [string]$ExternalIP.Trim() } else { '-' }
|
||||||
|
if ([string]::IsNullOrWhiteSpace($ext)) { $ext = '-' }
|
||||||
|
return "rdg302|$userPart|$target|$ext"
|
||||||
|
}
|
||||||
|
|
||||||
function Get-RdgGatewaySacEventType {
|
function Get-RdgGatewaySacEventType {
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory = $true)][int]$EventId,
|
[Parameter(Mandatory = $true)][int]$EventId,
|
||||||
@@ -4135,7 +4586,7 @@ function Start-LoginMonitor {
|
|||||||
if ($osKind.IsWorkstation) {
|
if ($osKind.IsWorkstation) {
|
||||||
Write-Log "Режим рабочей станции: Security — только LogonType 10 (RDP); при наличии журнала — событие 1149 (Remote Connection Manager)."
|
Write-Log "Режим рабочей станции: Security — только LogonType 10 (RDP); при наличии журнала — событие 1149 (Remote Connection Manager)."
|
||||||
} else {
|
} else {
|
||||||
Write-Log "Режим сервера: Security — LogonType 2, 3, 10"
|
Write-Log "Режим сервера: Security — LogonType 2, 3, 10; при журнале RCM — 1149 (если `$EnableRcm1149Monitoring)."
|
||||||
}
|
}
|
||||||
if (Test-MonitorFeatureEnabled -Value $EnableAdminShareMonitoring) {
|
if (Test-MonitorFeatureEnabled -Value $EnableAdminShareMonitoring) {
|
||||||
Write-Log "Admin share: Security 5140 для $($AdminShareMonitorSuffixes -join ', ') (audit File Share; workstation + server)."
|
Write-Log "Admin share: Security 5140 для $($AdminShareMonitorSuffixes -join ', ') (audit File Share; workstation + server)."
|
||||||
@@ -4202,9 +4653,15 @@ function Start-LoginMonitor {
|
|||||||
$rdGatewayAvailable = $false
|
$rdGatewayAvailable = $false
|
||||||
if ($EnableRDGatewayMonitoring) { $rdGatewayAvailable = Test-RDGatewayLog }
|
if ($EnableRDGatewayMonitoring) { $rdGatewayAvailable = Test-RDGatewayLog }
|
||||||
|
|
||||||
$rcmMonitoringEnabled = ($osKind.IsWorkstation -and (Test-RcmLogAvailable))
|
$rcmMonitoringEnabled = (Test-MonitorFeatureEnabled -Value $EnableRcm1149Monitoring) -and (Test-RcmLogAvailable)
|
||||||
if ($osKind.IsWorkstation -and -not $rcmMonitoringEnabled) {
|
if (Test-MonitorFeatureEnabled -Value $EnableRcm1149Monitoring) {
|
||||||
Write-Log "Рабочая станция: журнал Remote Connection Manager недоступен — уведомления только по Security 4624/4625 (LogonType 10). Проверьте, что включён удалённый рабочий стол."
|
if ($rcmMonitoringEnabled) {
|
||||||
|
Write-Log "RCM 1149: опрос включён ($RcmLogName)."
|
||||||
|
} elseif ($osKind.IsWorkstation) {
|
||||||
|
Write-Log "Рабочая станция: журнал Remote Connection Manager недоступен — уведомления только по Security 4624/4625 (LogonType 10). Проверьте, что включён удалённый рабочий стол."
|
||||||
|
} else {
|
||||||
|
Write-Log "Сервер: журнал Remote Connection Manager недоступен — RCM 1149 отключён; только Security 4624/4625."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$rcmShadowMonitoringEnabled = (Test-MonitorFeatureEnabled -Value $EnableRcmShadowControlMonitoring) -and (Test-RcmLogAvailable)
|
$rcmShadowMonitoringEnabled = (Test-MonitorFeatureEnabled -Value $EnableRcmShadowControlMonitoring) -and (Test-RcmLogAvailable)
|
||||||
@@ -4212,10 +4669,11 @@ function Start-LoginMonitor {
|
|||||||
$adminShareMonitoringEnabled = Test-MonitorFeatureEnabled -Value $EnableAdminShareMonitoring
|
$adminShareMonitoringEnabled = Test-MonitorFeatureEnabled -Value $EnableAdminShareMonitoring
|
||||||
|
|
||||||
$nextHeartbeatTime = (Get-Date).AddSeconds($HeartbeatInterval)
|
$nextHeartbeatTime = (Get-Date).AddSeconds($HeartbeatInterval)
|
||||||
|
$script:NextSacCommandPollTime = Get-Date
|
||||||
$nextInventoryTime = (Get-Date).AddSeconds($InventoryIntervalSec)
|
$nextInventoryTime = (Get-Date).AddSeconds($InventoryIntervalSec)
|
||||||
$nextRotationCheck = Check-AndRotateLog
|
$nextRotationCheck = Check-AndRotateLog
|
||||||
$nextReportCheck = Check-AndSendDailyReport
|
$nextReportCheck = Check-AndSendDailyReport
|
||||||
$lastCheckTime = (Get-Date).AddSeconds(-10)
|
$lastCheckTime = Get-RdpSecurityPollCursor
|
||||||
if ($rdGatewayAvailable) {
|
if ($rdGatewayAvailable) {
|
||||||
$lastGatewayCheckTime = Get-RdpGatewayPollCursor
|
$lastGatewayCheckTime = Get-RdpGatewayPollCursor
|
||||||
Write-Log "RD Gateway: опрос 302/303 включён, cursor=$($lastGatewayCheckTime.ToString('dd.MM.yyyy HH:mm:ss'))"
|
Write-Log "RD Gateway: опрос 302/303 включён, cursor=$($lastGatewayCheckTime.ToString('dd.MM.yyyy HH:mm:ss'))"
|
||||||
@@ -4225,12 +4683,12 @@ function Start-LoginMonitor {
|
|||||||
Write-Log "RD Gateway: журнал недоступен — опрос 302/303 отключён ($RDGatewayLogName)"
|
Write-Log "RD Gateway: журнал недоступен — опрос 302/303 отключён ($RDGatewayLogName)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$lastRcmCheckTime = (Get-Date).AddSeconds(-10)
|
$lastRcmCheckTime = Get-RdpRcm1149PollCursor
|
||||||
$lastRcmShadowCheckTime = (Get-Date).AddSeconds(-10)
|
$lastRcmShadowCheckTime = (Get-Date).AddSeconds(-10)
|
||||||
$lastWinRmCheckTime = (Get-Date).AddSeconds(-10)
|
$lastWinRmCheckTime = (Get-Date).AddSeconds(-10)
|
||||||
$lastAdminShare5140CheckTime = (Get-Date).AddSeconds(-10)
|
$lastAdminShare5140CheckTime = (Get-Date).AddSeconds(-10)
|
||||||
$lastLockout4740CheckTime = (Get-Date).AddSeconds(-10)
|
$lastLockout4740CheckTime = (Get-Date).AddSeconds(-10)
|
||||||
$monitorEvents = @(4624, 4625, 4648)
|
$monitorEvents = @(4624, 4625, 4634, 4647, 4648)
|
||||||
|
|
||||||
$script:MonitorInMainLoop = $true
|
$script:MonitorInMainLoop = $true
|
||||||
while ($true) {
|
while ($true) {
|
||||||
@@ -4296,6 +4754,24 @@ function Start-LoginMonitor {
|
|||||||
if ($event.Id -eq 4648) {
|
if ($event.Id -eq 4648) {
|
||||||
$shouldIgnore = $true
|
$shouldIgnore = $true
|
||||||
$ignoreReason = 'EventID 4648 excluded (MonitorInteractiveOnly)'
|
$ignoreReason = 'EventID 4648 excluded (MonitorInteractiveOnly)'
|
||||||
|
} elseif ($event.Id -in 4634, 4647) {
|
||||||
|
if (-not $osKind.IsWorkstation) {
|
||||||
|
$shouldIgnore = $true
|
||||||
|
$ignoreReason = 'logoff 4634/4647 only on workstations (server LT3 is Kerberos/LDAP noise)'
|
||||||
|
} else {
|
||||||
|
$interactiveTypes = @(10)
|
||||||
|
$modeLabel = 'workstation logoff LT10'
|
||||||
|
if ($interactiveTypes -notcontains $eventInfo.LogonType) {
|
||||||
|
$allow4647Workstation = (
|
||||||
|
$event.Id -eq 4647 -and
|
||||||
|
$eventInfo.LogonType -eq 0
|
||||||
|
)
|
||||||
|
if (-not $allow4647Workstation) {
|
||||||
|
$shouldIgnore = $true
|
||||||
|
$ignoreReason = "LogonType $($eventInfo.LogonType) not in $modeLabel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} elseif ($event.Id -in 4624, 4625) {
|
} elseif ($event.Id -in 4624, 4625) {
|
||||||
if ($osKind.IsWorkstation) {
|
if ($osKind.IsWorkstation) {
|
||||||
$interactiveTypes = @(10)
|
$interactiveTypes = @(10)
|
||||||
@@ -4347,6 +4823,46 @@ function Start-LoginMonitor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($event.Id -in 4634, 4647) {
|
||||||
|
$dedupKeyLogoff = Get-RdpLogoffNotifyDedupKey -SecurityLogComputerName $event.MachineName `
|
||||||
|
-Username $eventInfo.Username -LogonType $eventInfo.LogonType
|
||||||
|
if (-not (Test-RdpLogoffNotifyDedupAllow -DedupKey $dedupKeyLogoff)) {
|
||||||
|
Write-Log "Notify dedup logoff $($event.Id): User=$($eventInfo.Username) LT=$($eventInfo.LogonType) (window ${LoginSuccessNotifyDedupSeconds}s)"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
$formattedMessage = Format-LoginEvent -EventID $event.Id `
|
||||||
|
-Username $eventInfo.Username `
|
||||||
|
-ComputerName $eventInfo.ComputerName `
|
||||||
|
-SourceIP $eventInfo.SourceIP `
|
||||||
|
-ProcessName $eventInfo.ProcessName `
|
||||||
|
-TimeCreated $eventInfo.TimeCreated `
|
||||||
|
-LogonType $eventInfo.LogonType `
|
||||||
|
-LogonTypeName $logonTypeName `
|
||||||
|
-SecurityLogComputerName $event.MachineName
|
||||||
|
|
||||||
|
$sacDetails = @{
|
||||||
|
user = $eventInfo.Username
|
||||||
|
ip_address = $eventInfo.SourceIP
|
||||||
|
logon_type = $eventInfo.LogonType
|
||||||
|
event_id_windows = [int]$event.Id
|
||||||
|
workstation_name = $eventInfo.ComputerName
|
||||||
|
}
|
||||||
|
if ($null -ne $eventInfo.SessionId -and -not [string]::IsNullOrWhiteSpace([string]$eventInfo.SessionId)) {
|
||||||
|
$sacDetails['session_id'] = [string]$eventInfo.SessionId
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Log "Notify logoff: ID=$($event.Id) User=$($eventInfo.Username) LT=$($eventInfo.LogonType) IP=$($eventInfo.SourceIP)"
|
||||||
|
Send-MonitorNotification -Message $formattedMessage `
|
||||||
|
-EmailSubject "RDP Login Monitor: выход (ID $($event.Id))" `
|
||||||
|
-SacEventType 'rdp.session.logoff' -SacSeverity 'info' `
|
||||||
|
-SacTitle "RDP session logoff $($event.Id)" `
|
||||||
|
-SacSummary "Logoff $($event.Id) $($eventInfo.Username)" `
|
||||||
|
-SacOccurredAt $eventInfo.TimeCreated `
|
||||||
|
-SacDetails $sacDetails | Out-Null
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if ($event.Id -eq 4625 -and $FailedLogonRateLimitEnabled) {
|
if ($event.Id -eq 4625 -and $FailedLogonRateLimitEnabled) {
|
||||||
$rl = Get-FailedLogonRateLimitDecision4625 -SourceIP $eventInfo.SourceIP `
|
$rl = Get-FailedLogonRateLimitDecision4625 -SourceIP $eventInfo.SourceIP `
|
||||||
-ComputerName $eventInfo.ComputerName -Username $eventInfo.Username `
|
-ComputerName $eventInfo.ComputerName -Username $eventInfo.Username `
|
||||||
@@ -4425,6 +4941,7 @@ function Start-LoginMonitor {
|
|||||||
}
|
}
|
||||||
$lastCheckTime = Update-MonitorPollCursor -CurrentCursor $lastCheckTime -Events @($events)
|
$lastCheckTime = Update-MonitorPollCursor -CurrentCursor $lastCheckTime -Events @($events)
|
||||||
}
|
}
|
||||||
|
Set-RdpSecurityPollCursor -Cursor $lastCheckTime
|
||||||
|
|
||||||
if ($rdGatewayAvailable) {
|
if ($rdGatewayAvailable) {
|
||||||
Set-MonitorLoopPhase -Phase 'poll_rdgateway'
|
Set-MonitorLoopPhase -Phase 'poll_rdgateway'
|
||||||
@@ -4449,6 +4966,14 @@ function Start-LoginMonitor {
|
|||||||
Write-Log "Skip RDG 303: ephemeral channel (SessionDuration=0, User=$($ei.Username), Target=$($ei.InternalIP), ErrorCode=$($ei.ErrorCode))"
|
Write-Log "Skip RDG 303: ephemeral channel (SessionDuration=0, User=$($ei.Username), Target=$($ei.InternalIP), ErrorCode=$($ei.ErrorCode))"
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if ($event.Id -eq 302) {
|
||||||
|
$dedupKey302 = Get-Rdg302NotifyDedupKey -Username $ei.Username `
|
||||||
|
-InternalIP $ei.InternalIP -ExternalIP $ei.ExternalIP
|
||||||
|
if (Test-RdpMonitorNotifyDedup -Key $dedupKey302 -WindowSeconds $Rdg302NotifyDedupSeconds) {
|
||||||
|
Write-Log "Notify dedup RDG 302: User=$($ei.Username) Target=$($ei.InternalIP) Ext=$($ei.ExternalIP) (window ${Rdg302NotifyDedupSeconds}s)"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
$msg = Format-RDGatewayEvent -EventID $event.Id `
|
$msg = Format-RDGatewayEvent -EventID $event.Id `
|
||||||
-Username $ei.Username `
|
-Username $ei.Username `
|
||||||
-ExternalIP $ei.ExternalIP `
|
-ExternalIP $ei.ExternalIP `
|
||||||
@@ -4495,12 +5020,41 @@ function Start-LoginMonitor {
|
|||||||
} -ErrorAction SilentlyContinue
|
} -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
if ($rcmEvents) {
|
if ($rcmEvents) {
|
||||||
|
$rcmNotified = 0
|
||||||
|
$rcmSkipped = 0
|
||||||
foreach ($event in $rcmEvents) {
|
foreach ($event in $rcmEvents) {
|
||||||
if ($event.TimeCreated -le $lastRcmCheckTime) { continue }
|
if ($event.TimeCreated -le $lastRcmCheckTime) { continue }
|
||||||
$rcmInfo = Get-Rcm1149EventInfo -Event $event
|
$rcmInfo = Get-Rcm1149EventInfo -Event $event
|
||||||
if ($rcmInfo.Username -like "*$") { continue }
|
if ($rcmInfo.Username -eq '-' -or [string]::IsNullOrWhiteSpace($rcmInfo.Username)) {
|
||||||
|
$fromQw = Get-Rcm1149UsernameFromQwinsta
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($fromQw)) {
|
||||||
|
Write-Log "RCM 1149: EventLog user empty — qwinsta fallback User=$fromQw"
|
||||||
|
$rcmInfo.Username = $fromQw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($rcmInfo.Username -like "*$") {
|
||||||
|
$rcmSkipped++
|
||||||
|
Write-Log "Skip 1149: User=$($rcmInfo.Username) IP=$($rcmInfo.ClientIP) — machine account"
|
||||||
|
continue
|
||||||
|
}
|
||||||
if (Should-IgnoreEvent -Username $rcmInfo.Username -ProcessName "-" `
|
if (Should-IgnoreEvent -Username $rcmInfo.Username -ProcessName "-" `
|
||||||
-ComputerName "-" -EventID 1149 -LogonType 10 -SourceIP $rcmInfo.ClientIP) { continue }
|
-ComputerName "-" -EventID 1149 -LogonType 10 -SourceIP $rcmInfo.ClientIP) {
|
||||||
|
$rcmSkipped++
|
||||||
|
$ud = Get-Rcm1149UserDataEventInfoMap -Event $event
|
||||||
|
$p1 = if ($ud.ContainsKey('Param1')) { $ud['Param1'] } else { '' }
|
||||||
|
$p2 = if ($ud.ContainsKey('Param2')) { $ud['Param2'] } else { '' }
|
||||||
|
$p3 = if ($ud.ContainsKey('Param3')) { $ud['Param3'] } else { '' }
|
||||||
|
Write-Log "Skip 1149: User=$($rcmInfo.Username) IP=$($rcmInfo.ClientIP) — built-in exclusion or ignore.lst (EventXML Param1=$p1 Param2=$p2 Param3=$p3)"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
$dedupKey1149 = Get-RdpLoginSuccessNotifyDedupKey -SecurityLogComputerName $event.MachineName `
|
||||||
|
-Username $rcmInfo.Username -SourceIP $rcmInfo.ClientIP -LogonType 10
|
||||||
|
if (-not (Test-RdpLoginSuccessNotifyDedupAllow -DedupKey $dedupKey1149)) {
|
||||||
|
$rcmSkipped++
|
||||||
|
Write-Log "Notify dedup 1149: User=$($rcmInfo.Username) IP=$($rcmInfo.ClientIP) (window ${LoginSuccessNotifyDedupSeconds}s)"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
$msg = Format-Rcm1149Event -Username $rcmInfo.Username -ClientIP $rcmInfo.ClientIP `
|
$msg = Format-Rcm1149Event -Username $rcmInfo.Username -ClientIP $rcmInfo.ClientIP `
|
||||||
-TimeCreated $rcmInfo.TimeCreated -SecurityLogComputerName $event.MachineName
|
-TimeCreated $rcmInfo.TimeCreated -SecurityLogComputerName $event.MachineName
|
||||||
@@ -4516,8 +5070,13 @@ function Start-LoginMonitor {
|
|||||||
ip_address = $rcmInfo.ClientIP
|
ip_address = $rcmInfo.ClientIP
|
||||||
event_id_windows = 1149
|
event_id_windows = 1149
|
||||||
} | Out-Null
|
} | Out-Null
|
||||||
|
$rcmNotified++
|
||||||
}
|
}
|
||||||
$lastRcmCheckTime = Update-MonitorPollCursor -CurrentCursor $lastRcmCheckTime -Events @($rcmEvents)
|
$lastRcmCheckTime = Update-MonitorPollCursor -CurrentCursor $lastRcmCheckTime -Events @($rcmEvents)
|
||||||
|
Set-RdpRcm1149PollCursor -Cursor $lastRcmCheckTime
|
||||||
|
if ($rcmNotified -gt 0 -or $rcmSkipped -gt 0) {
|
||||||
|
Write-Log "RCM 1149 poll: fetched=$(@($rcmEvents).Count) notified=$rcmNotified skipped=$rcmSkipped cursor=$($lastRcmCheckTime.ToString('dd.MM.yyyy HH:mm:ss'))"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4740,7 +5299,11 @@ function Start-LoginMonitor {
|
|||||||
$nextReportCheck = Check-AndSendDailyReport
|
$nextReportCheck = Check-AndSendDailyReport
|
||||||
}
|
}
|
||||||
if ($script:SacClientLoaded) {
|
if ($script:SacClientLoaded) {
|
||||||
Invoke-SacFlushSpool -MaxFiles 5 | Out-Null
|
Invoke-SacFlushSpool | Out-Null
|
||||||
|
if ((Get-SacNormalizedMode) -ne 'off' -and $now -ge $script:NextSacCommandPollTime) {
|
||||||
|
Invoke-SacProcessPendingCommands | Out-Null
|
||||||
|
$script:NextSacCommandPollTime = $now.AddSeconds($SacCommandPollIntervalSec)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw }
|
if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw }
|
||||||
|
|||||||
@@ -1,234 +1,62 @@
|
|||||||
# RDP Login Monitor
|
# RDP Login Monitor
|
||||||
|
|
||||||
PowerShell-набор для мониторинга входов в Windows с уведомлениями в Telegram и/или Email (SMTP).
|
**Версия:** `2.1.14-SAC` (`$ScriptVersion` + `version.txt`)
|
||||||
|
|
||||||
## Быстрый старт (1 команда с шары)
|
PowerShell-мониторинг Windows: RDP/RDS, RD Gateway, WinRM, admin share, блокировки УЗ, heartbeat, отчёты.
|
||||||
|
|
||||||
Для **первичной установки на новый сервер** используйте только deploy-скрипт с шары:
|
## Установка (домен)
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
powershell -NoProfile -ExecutionPolicy Bypass -File "\\<DC>\NETLOGON\RDP-login-monitor\Deploy-LoginMonitor.ps1"
|
powershell -NoProfile -ExecutionPolicy Bypass -File "\\<DC>\NETLOGON\RDP-login-monitor\Deploy-LoginMonitor.ps1"
|
||||||
```
|
```
|
||||||
|
|
||||||
Скрипт сам:
|
Каталог: `C:\ProgramData\RDP-login-monitor\` · настройки: `login_monitor.settings.ps1` (не перезаписываются при деплое).
|
||||||
- создаст `C:\ProgramData\RDP-login-monitor\`;
|
|
||||||
- скопирует `Login_Monitor.ps1`, `Sac-Client.ps1` и служебные файлы;
|
## Обновление через SAC (WinRM)
|
||||||
- создаст/обновит задачи `RDP-Login-Monitor` и `RDP-Login-Monitor-Watchdog`;
|
|
||||||
- запустит монитор;
|
С карточки хоста в SAC: **«Обновить через WinRM»** (SAC ≥ 0.20.15). Сервер тянет репозиторий с git, отдаёт zip по HTTPS; на ПК **не нужны** git и NETLOGON.
|
||||||
- при необходимости создаст/дополнит `login_monitor.settings.ps1`;
|
|
||||||
- проверит WinRM Operational и попробует включить канал.
|
1. Staging: `C:\ProgramData\RDP-login-monitor\_sac_staging`
|
||||||
|
2. `Deploy-LoginMonitor.ps1 -SourceShareRoot` (тот же алгоритм, что при GPO)
|
||||||
|
3. Нужны: WinRM, domain admin в SAC, доступ ПК к `SAC_PUBLIC_URL`, `rdp_git_repo_url` в **Настройки → Обновления агентов**
|
||||||
|
|
||||||
|
Подробнее: [security-alert-center — agent-control-plane](https://git.kalinamall.ru/PapaTramp/security-alert-center/src/branch/main/docs/agent-control-plane.md) §4.3.
|
||||||
|
|
||||||
|
## События в SAC
|
||||||
|
|
||||||
|
| Источник | Тип SAC |
|
||||||
|
|----------|---------|
|
||||||
|
| Security 4624/4625 | `rdp.login.*` |
|
||||||
|
| Security 4634/4647 (прямой RDP, **только рабочая станция**, LT10) | `rdp.session.logoff` → закрытие сессии в SAC |
|
||||||
|
| Security 5140 | `smb.admin_share.access` |
|
||||||
|
| WinRM Operational 91 | `winrm.session.started` |
|
||||||
|
| RD Gateway 302/303 | `rdg.connection.*` → flap 302→303 в SAC |
|
||||||
|
| Security 4740 (КД) | `auth.account.locked` |
|
||||||
|
| Агент | `agent.heartbeat`, `report.daily.rdp`, `agent.inventory` |
|
||||||
|
|
||||||
|
**SAC:** `Sac-Client.ps1`, `$UseSAC` — [agent-integration.md](https://git.kalinamall.ru/PapaTramp/security-alert-center/src/branch/main/docs/agent-integration.md). Poll команд `qwinsta`/`logoff` (≥ 2.1.0-SAC).
|
||||||
|
|
||||||
|
## Ключевые файлы
|
||||||
|
|
||||||
|
| Файл | Назначение |
|
||||||
|
|------|------------|
|
||||||
|
| `Login_Monitor.ps1` | Основной цикл |
|
||||||
|
| `Sac-Client.ps1` | Отправка в SAC |
|
||||||
|
| `Deploy-LoginMonitor.ps1` | NETLOGON, задачи, WinRM self-heal |
|
||||||
|
| `login_monitor.settings.ps1` | Секреты и параметры (локально) |
|
||||||
|
|
||||||
|
## Быстрые проверки
|
||||||
|
|
||||||
Проверка после установки (1 команда):
|
|
||||||
```powershell
|
```powershell
|
||||||
Get-Content "C:\ProgramData\RDP-login-monitor\Logs\login_monitor.log" -Tail 60
|
Get-Content "C:\ProgramData\RDP-login-monitor\Logs\login_monitor.log" -Tail 60
|
||||||
|
Login_Monitor.ps1 -CheckSac
|
||||||
```
|
```
|
||||||
|
|
||||||
## Актуальная схема (рекомендуется)
|
## Документация
|
||||||
|
|
||||||
- Базовый путь установки: **`C:\ProgramData\RDP-login-monitor\`**.
|
- [Docs/README.md](Docs/README.md) — развёртывание, Exchange
|
||||||
- Основной скрипт: **`Login_Monitor.ps1`** — журнал Security **`4624`/`4625`** (логика зависит от типа ОС: рабочая станция или сервер/КД), при всплеске **`4625`** — **агрегированные оповещения** (два порога: IP+пользователь и только IP), при наличии журнала — **Remote Connection Manager `1149`** (часто актуально для РС с RDP), **RDS Shadow Control** (`20506`/`20507`/`20510`, severity warning), **WinRM inbound / Enter-PSSession** (Operational `91`, severity warning), **admin share `C$`/`ADMIN$`** (Security **5140**, severity warning), при роли **RD Gateway** — **`302`/`303`**, на **КД, где запущен монитор** (имя совпадает с **`$LockoutMonitorDomainController`**) — **`4740`** (блокировка УЗ + IP из IIS ActiveSync), **ежедневный отчёт** (активные сессии через `quser`), **heartbeat**, **ротация логов**, уведомления в Telegram и/или Email.
|
- [security-alert-center](https://git.kalinamall.ru/PapaTramp/security-alert-center) — сервер, RDG flap, qwinsta через SAC UI/Seaca
|
||||||
- Установка задач: запуск **`Login_Monitor.ps1 -InstallTasks`** создаёт:
|
|
||||||
- `RDP-Login-Monitor` (основной монитор),
|
|
||||||
- `RDP-Login-Monitor-Watchdog` (контроль процесса каждые 5 минут).
|
|
||||||
- Доменная доставка и обновления: **`Deploy-LoginMonitor.ps1`** + **`version.txt`** с шары `NETLOGON`. После успешного деплоя в приветственном сообщении (Telegram/Email) может появиться отметка об обновлении (файл **`deploy_last_update.txt`** рядом с логами).
|
|
||||||
- Документация по развёртыванию: **[Docs/README.md](Docs/README.md)** (RDP-монитор, Exchange, NETLOGON).
|
|
||||||
- **`Encrypt-DpapiForRdpMonitor.ps1`** — опционально для подготовки DPAPI-строк токена/chat id и пароля SMTP (`$MailSmtpPasswordProtectedB64` в файле настроек).
|
|
||||||
- **Локальные настройки RDP-монитора:** **`login_monitor.settings.ps1`** в каталоге установки (образец **`login_monitor.settings.example.ps1`**). При автообновлении **`Login_Monitor.ps1`** с шары файл настроек **не перезаписывается** (как **`exchange_monitor.settings.ps1`** для Exchange).
|
|
||||||
- **Security Alert Center (SAC):** модуль **`Sac-Client.ps1`** (копируется вместе с `Login_Monitor.ps1`). Режимы **`$UseSAC`**: `off` | `exclusive` | `dual` | `fallback` — контракт в репозитории **security-alert-center** (`docs/agent-integration.md`). Версия релиза: **`$ScriptVersion`** и **`version.txt`** (сейчас **2.0.20-SAC**); **`Sac-Client.ps1`** передаёт ту же версию в SAC (`product_version`).
|
|
||||||
|
|
||||||
### Highlights (события в SAC / Telegram)
|
|
||||||
|
|
||||||
| Источник | Что ловится | Тип SAC (пример) |
|
|
||||||
|----------|-------------|------------------|
|
|
||||||
| Security 4624/4625 | RDP/RDS входы | `rdp.login.success` / `rdp.login.failed` |
|
|
||||||
| Security **5140** | Доступ к **`C$`**, **`ADMIN$`** | `smb.admin_share.access` |
|
|
||||||
| WinRM Operational 91 | Enter-PSSession / PS Remoting | `winrm.session.started` |
|
|
||||||
| RCM Operational | RDS Shadow Control | `rdp.shadow.control.*` |
|
|
||||||
| RD Gateway | 302/303 | `rdg.connection.*` |
|
|
||||||
| Security 4740 | Блокировка УЗ (на заданном КД) | `auth.account.locked` |
|
|
||||||
| Агент | Heartbeat, отчёт, lifecycle | `agent.heartbeat`, `report.daily.rdp` |
|
|
||||||
|
|
||||||
## Что изменилось (важное)
|
|
||||||
|
|
||||||
- **Локальные настройки RDP-монитора** вынесены в **`login_monitor.settings.ps1`** (образец **`login_monitor.settings.example.ps1`**). При автообновлении **`Login_Monitor.ps1`** с шары секреты и параметры КД **не слетают**. Deploy при отсутствии settings создаёт файл один раз из example на шаре.
|
|
||||||
- **Кодировка `.ps1`**: в репозитории добавлены `.editorconfig` и `.gitattributes`, чтобы `*.ps1` по умолчанию сохранялись как **UTF-8 with BOM** и с **CRLF** (это сильно снижает “кракозябры” и ошибки парсинга PowerShell).
|
|
||||||
- **Кодировка логов**: `login_monitor.log` / `watchdog.log` пишутся как **UTF-8 с BOM** (и при необходимости BOM добавляется к уже существующему файлу), чтобы в **FAR/старых просмотрщиках** не было ситуации “в консоли нормально, а в файле РЈРІРµ…” из‑за неверной авто-кодировки.
|
|
||||||
- **`auditpol` на русской Windows**: настройка/проверка аудита опирается на категорию **`Вход/выход`** и подкатегории **`Вход в систему` / `Выход из системы`** (ожидается **`Успех и сбой`** или **`Успех и отказ`** — формулировка зависит от сборки ОС). Это устраняет ошибки вида `0x00000057` из‑за несуществующего на RU ОС имени `Logon`.
|
|
||||||
- **Стабильность**: `auditpol` вызывается по полному пути `%SystemRoot%\System32\auditpol.exe` (без зависимости от PATH), stdout+stderr объединяются через `ProcessStartInfo`.
|
|
||||||
- **Агрегация 4625 (брутфорс)**: при включённом `$FailedLogonRateLimitEnabled` — уровень 1: **5** неудачных попыток за **60** с с одного источника для **одной** учётной записи (IP+user); уровень 2: **12** попыток за **60** с с одного IP (несколько логинов). Пока порог не достигнут — поштучные 4625; при всплеске — сводные алерты, одиночные подавляются. Параметры в начале `Login_Monitor.ps1`. Автоблокировка IP не выполняется.
|
|
||||||
- **Exchange Mail Security** (`Exchange-MailSecurity.ps1`): на **сервере Exchange** — очереди, пересылка на внешние адреса, watchdog. Руководство: **[Docs/exchange-mail-security.md](Docs/exchange-mail-security.md)**. GPO **`Deploy-LoginMonitor.ps1`** на MailServer ставит тот же RDP-монитор и дописывает WinRM/4624 noise в **`login_monitor.settings.ps1`**; **`Deploy-DomainMonitors.ps1 -Target Exchange`** — отдельно.
|
|
||||||
- **Admin share 5140 (2.0.19+):** Security **5140** для **`C$`/`ADMIN$`** (audit File Share); SAC `smb.admin_share.access`; подавление в `ignore.lst` (`smb:` / `5140:`).
|
|
||||||
- **WinRM / Enter-PSSession (2.0.4+):** улучшен парсинг события `Microsoft-Windows-WinRM/Operational` (`ID=91`) — корректно извлекаются `user` и `clientIP` из EventData/Message fallback, событие отправляется в SAC как `winrm.session.started`.
|
|
||||||
- **Deploy WinRM self-heal (2.0.3+):** `Deploy-LoginMonitor.ps1` проверяет/дописывает обязательный блок WinRM inbound в `login_monitor.settings.ps1`, диагностирует доступность канала `Microsoft-Windows-WinRM/Operational` и пытается включить его через `wevtutil`.
|
|
||||||
- **Диагностика skip WinRM (2.0.5+):** в логах пишется точная причина `Skip WinRM 91` (`empty-user`, `excluded-user-pattern`, `machine-account`, `local-or-linklocal-ip`, `ignore-list-match`), что упрощает troubleshooting.
|
|
||||||
- **WinRM Exchange strict (2.0.23+):** на сервере с ролью Exchange — WinRM **91** без user в EventData/Properties не алертится; корреляция Security **4624** только при `LogonProcess WinRM` (отсекает ложные связки с Outlook/почтовым LT3). Причины skip: `exchange-winrm-no-user-in-event`, `exchange-winrm-unresolved-uri-no-ip`. Deploy дописывает `$WinRmExchangeStrictMode = 1`.
|
|
||||||
|
|
||||||
## 1) Подготовка
|
|
||||||
|
|
||||||
1. Подготовьте папку установки:
|
|
||||||
- `C:\ProgramData\RDP-login-monitor\`
|
|
||||||
2. Скопируйте в неё как минимум:
|
|
||||||
- `Login_Monitor.ps1`
|
|
||||||
- `Sac-Client.ps1`
|
|
||||||
- `login_monitor.settings.example.ps1` → переименуйте в **`login_monitor.settings.ps1`** и задайте параметры (см. п. 3)
|
|
||||||
- (для доменного развёртывания отдельно на шаре) `Deploy-LoginMonitor.ps1`, `version.txt` и `login_monitor.settings.example.ps1`
|
|
||||||
3. Настройте **`C:\ProgramData\RDP-login-monitor\login_monitor.settings.ps1`** (не редактируйте секреты в `Login_Monitor.ps1` — они перезапишутся при деплое):
|
|
||||||
- **Telegram:** `$TelegramBotToken` / `$TelegramChatID` или `...ProtectedB64`
|
|
||||||
- **Email (SMTP):** `$MailSmtpHost`, `$MailFrom`, `$MailTo`, `$MailSmtpPort` (по умолчанию 587), при необходимости `$MailSmtpUser` / `$MailSmtpPassword` (или `$MailSmtpPasswordProtectedB64` через DPAPI), `$MailSmtpStartTls` / `$MailSmtpSsl`
|
|
||||||
- **Порядок:** `$NotifyOrder` — пусто = авто (Telegram → Email, только настроенные); иначе `telegram,email` или `email` и т.п. (допускаются `tg`, `mail`)
|
|
||||||
- **SAC (опционально):** `$UseSAC`, `$SacUrl`, `$SacApiKey` — см. блок в **`login_monitor.settings.example.ps1`**. Проверка: `Login_Monitor.ps1 -CheckSac`
|
|
||||||
- **IP хоста для SAC (опционально):** `$ServerIPv4` — явный IPv4 для `host.ipv4`; если не задан, берётся автоопределение
|
|
||||||
- **Exchange-шум 4624 (опционально):** `${Ignore4624-LT3-EmptyIP-Event} = $true` — подавляет `4624` c `LogonType=3` и `IP='-'` (часто Outlook/SMTP-клиенты на почтовом сервере)
|
|
||||||
- Пошаговое обновление по домену: **[Docs/deploy-rdp-login-monitor.md](Docs/deploy-rdp-login-monitor.md)** (раздел «Обновление на любой Windows-машине»)
|
|
||||||
4. Запускайте с правами администратора (чтение `Security` журнала и регистрация задач).
|
|
||||||
5. Логи и служебные файлы будут в:
|
|
||||||
- `C:\ProgramData\RDP-login-monitor\Logs\`
|
|
||||||
6. (Опционально) Подавление части алертов по списку — см. раздел **«7) ignore.lst»** ниже.
|
|
||||||
7. (Опционально) Мониторинг блокировок AD на КД — в **`login_monitor.settings.ps1`**: **`$LockoutMonitorDomainController`** (короткое имя узла, на котором **установлен и запущен** монитор), **`$NetBiosDomainName`**, **`$ExchangeIisLogPath`**, **`$ExchangeIisLogMinutesBeforeLockout`** (по умолчанию 30), **`$ExchangeIisLogTailLines`** (по умолчанию 5000), **`$ExchangeServerHostForIisExclude`**. В оповещении: пользователь из 4740 и IP из IIS за окно до блокировки. В **`ignore.lst`** префикс **`4740:`** или **`all:`** — см. **`ignore.lst.example`**.
|
|
||||||
8. Heartbeat: при отсутствии обновления **`Logs\last_heartbeat.txt`** дольше **`$HeartbeatStaleAlertMultiplier` × `$HeartbeatInterval`** (по умолчанию 2×1 ч) — оповещение в Telegram/Email.
|
|
||||||
|
|
||||||
## 2) Ручной запуск
|
|
||||||
|
|
||||||
Используйте этот вариант для быстрой проверки старта/логики без установки задач планировщика.
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\RDP-login-monitor\Login_Monitor.ps1"
|
|
||||||
```
|
|
||||||
|
|
||||||
Примечание: при ручном запуске монитор работает в текущей сессии до остановки (например, `Ctrl+C`).
|
|
||||||
|
|
||||||
## 3) Запуск через Планировщик заданий (Task Scheduler)
|
|
||||||
|
|
||||||
Текущая схема: вручную задачи в GUI создавать не нужно.
|
|
||||||
|
|
||||||
Достаточно запустить:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\RDP-login-monitor\Login_Monitor.ps1" -InstallTasks
|
|
||||||
```
|
|
||||||
|
|
||||||
Скрипт сам зарегистрирует `RDP-Login-Monitor` и `RDP-Login-Monitor-Watchdog`, а также запросит немедленный первый запуск задач.
|
|
||||||
|
|
||||||
Для доменной установки/обновления с шары вручную ничего в планировщике на клиенте настраивать не требуется: используйте `Deploy-LoginMonitor.ps1` (подробно в [Docs/deploy-rdp-login-monitor.md](Docs/deploy-rdp-login-monitor.md)).
|
|
||||||
|
|
||||||
## 4) Что проверять после запуска
|
|
||||||
|
|
||||||
- Логи:
|
|
||||||
- `C:\ProgramData\RDP-login-monitor\Logs\login_monitor.log`
|
|
||||||
- `C:\ProgramData\RDP-login-monitor\Logs\watchdog.log`
|
|
||||||
- Heartbeat:
|
|
||||||
- `C:\ProgramData\RDP-login-monitor\Logs\last_heartbeat.txt` обновляется по интервалу **`$HeartbeatInterval`** (по умолчанию раз в час).
|
|
||||||
- Ежедневный отчёт: после первого прохождения дневного слота (по умолчанию **09:00**, задаётся **`$DailyReportHour`** / **`$DailyReportMinute`** в `Login_Monitor.ps1`) уходит сводка по **`quser`** (Telegram/Email); метка последнего отчёта — `Logs\last_daily_report.txt`.
|
|
||||||
- Stale heartbeat: если **`last_heartbeat.txt`** не обновлялся дольше **`$HeartbeatStaleAlertMultiplier` × `$HeartbeatInterval`** — оповещение в Telegram/Email (см. п. 8 подготовки).
|
|
||||||
- При старте в Telegram/Email: строка **«Каналы уведомлений»** (фактический порядок доставки), плюс режим RDS/4740 по конфигурации.
|
|
||||||
- Telegram при старте: при установленном **RD Session Host** (или аналогичных компонентах RDS, не только шлюз) — строка про входы по RDP/RDS на этом сервере; при доступном журнале **RD Gateway** — отдельная строка про подключения к **внутренним целевым ПК** через шлюз (302/303). Узел только с ролью RD Gateway не дублирует формулировку «хост сессий».
|
|
||||||
- **Дубли Telegram на один RDP-вход:** Windows часто пишет **несколько 4624** с одним временем; с версии **1.2.18-SAC** второе уведомление за **`$LoginSuccessNotifyDedupSeconds`** (90 с) подавляется (`Notify dedup 4624` в логе).
|
|
||||||
- **В логе нет `Notify`, но 4624 в Security есть:** монитор обрабатывает только события **после** своего `StartTime` (окно опроса ~10 с при старте). Ищите строки **`Skip 4624:`** (фильтр LogonType / ignore.lst). Диагностика: **`tools\Show-Rdp4624Recent.ps1`**.
|
|
||||||
|
|
||||||
## 4.1) Контрольный список стабилизации (5 минут)
|
|
||||||
|
|
||||||
После деплоя на сервер быстро проверьте, что WinRM/Enter-PSSession действительно проходит весь путь до SAC:
|
|
||||||
|
|
||||||
1. **WinRM Operational включён**
|
|
||||||
```powershell
|
|
||||||
Get-WinEvent -ListLog 'Microsoft-Windows-WinRM/Operational' |
|
|
||||||
Select-Object LogName, IsEnabled, RecordCount
|
|
||||||
```
|
|
||||||
2. **Тестовый вход с другой машины**
|
|
||||||
```powershell
|
|
||||||
Enter-PSSession -ComputerName <SERVER> -Credential <DOMAIN\USER>
|
|
||||||
```
|
|
||||||
3. **На целевом сервере есть событие ID=91**
|
|
||||||
```powershell
|
|
||||||
Get-WinEvent -LogName 'Microsoft-Windows-WinRM/Operational' -MaxEvents 50 |
|
|
||||||
Where-Object Id -eq 91 |
|
|
||||||
Select-Object TimeCreated, Id, Message
|
|
||||||
```
|
|
||||||
4. **В логе агента нет ложного skip**
|
|
||||||
```powershell
|
|
||||||
Get-Content 'C:\ProgramData\RDP-login-monitor\Logs\login_monitor.log' -Tail 120 |
|
|
||||||
Select-String -Pattern 'WinRM 91|Skip WinRM|reason='
|
|
||||||
```
|
|
||||||
Для версии `2.0.5-SAC+` при skip указывается конкретная причина: `empty-user`, `excluded-user-pattern`, `machine-account`, `local-or-linklocal-ip`, `ignore-list-match`.
|
|
||||||
5. **Событие ушло в SAC**
|
|
||||||
- В UI SAC (`/events`) есть запись типа `winrm.session.started` от нужного хоста.
|
|
||||||
|
|
||||||
Минимальная настройка шумоподавления (если нужно):
|
|
||||||
- не используйте `all:` в `ignore.lst` для рабочих админских учёток;
|
|
||||||
- для WinRM подавление задавайте адресно: `winrm:user:DOMAIN\\service` или `winrm:ip:x.x.x.x`;
|
|
||||||
- при проверке troubleshooting временно отключайте лишние исключения и проверяйте повторно.
|
|
||||||
|
|
||||||
## 5) Автоматический перезапуск при падении
|
|
||||||
|
|
||||||
Режим `-Watchdog` внутри `Login_Monitor.ps1` делает:
|
|
||||||
|
|
||||||
- проверяет, есть ли процесс `powershell.exe/pwsh.exe` с `Login_Monitor.ps1` в командной строке;
|
|
||||||
- если процесса нет — запускает монитор;
|
|
||||||
- если монитор уже есть — не дублирует экземпляр.
|
|
||||||
|
|
||||||
## 6) Дополнительные параметры и прочие файлы
|
|
||||||
|
|
||||||
- **`-SkipScheduledTaskMaintenance`**: при обычном запуске монитора не выполнять проверку/пересоздание задач планировщика (если регистрацию задач ведёте только через **`-InstallTasks`** или вручную).
|
|
||||||
- **`Install-DeployScheduledTask.ps1`** — helper для периодического запуска **`Deploy-LoginMonitor.ps1`** с шары (см. **[DEPLOY.md](DEPLOY.md)**).
|
|
||||||
- **`Watchdog_RDP_Monitor.ps1`** и **`Install-ScheduledTasks.ps1`** — **альтернативная** схема с отдельным watchdog-файлом и путями по умолчанию **`D:\Soft`**. Для новых установок рекомендуется встроенный режим **`-Watchdog`** в **`Login_Monitor.ps1`** и задачи **`RDP-Login-Monitor`** / **`RDP-Login-Monitor-Watchdog`**.
|
|
||||||
- **`ignore.lst.example`** в репозитории — образец файла **`ignore.lst`** для подавления отдельных уведомлений Security (см. раздел 7).
|
|
||||||
- **`Diagnose-RdpLoginMonitor.ps1`** — сбор диагностики после RDP-входа (Security 4624/4778, хвост `login_monitor.log`, симуляция фильтров монитора). Отчёт в **`Logs\diagnose_*.txt`**. Запуск:
|
|
||||||
```powershell
|
|
||||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\RDP-login-monitor\Diagnose-RdpLoginMonitor.ps1" -MinutesBack 15 -ExpectedUser "ваш_логин"
|
|
||||||
```
|
|
||||||
- **`login_monitor.settings.example.ps1`** — образец **`login_monitor.settings.ps1`** (Telegram, SMTP, 4740, локальные IP-исключения). Deploy при первой установке может создать `login_monitor.settings.ps1` из example автоматически.
|
|
||||||
|
|
||||||
## 7) Подавление уведомлений Security: `ignore.lst`
|
|
||||||
|
|
||||||
В каталоге установки можно положить файл **`C:\ProgramData\RDP-login-monitor\ignore.lst`** (рядом с **`Login_Monitor.ps1`**). По умолчанию правила относятся к **`4624`/`4625`**; префикс **`4740:`** (или **`lockout:`**, **`блокир:`**) — только к блокировкам учётной записи; **`all:`** — и входы, и **4740**. Для **4740** тип **`ip:`** сравнивается с IP из IIS ActiveSync. Жёсткие исключения в скрипте по-прежнему для всех типов событий, кроме **4740** (там только `ignore.lst` и встроенные проверки пользователя).
|
|
||||||
|
|
||||||
События **RD Gateway (`302`/`303`)**, **RCM `1149`**, ежедневный отчёт и heartbeat **этим файлом не настраиваются**.
|
|
||||||
|
|
||||||
### Как читается файл
|
|
||||||
|
|
||||||
- Чтение выполняется по мере обработки событий; содержимое **кэшируется в памяти**. Если **`LastWriteTimeUtc`** файла изменился (редактирование и сохранение), список **перечитывается автоматически** — перезапуск монитора не обязателен.
|
|
||||||
- Кодировка: **UTF-8** (`Get-Content -Encoding UTF8`). Строка может начинаться с BOM — он отбрасывается при разборе.
|
|
||||||
- Пустые строки пропускаются. Строки, начинающиеся с **`#`** или **`;`**, считаются комментариями.
|
|
||||||
- Строка с **`:`**: берётся **первая** двоеточие — всё слева (после обрезки пробелов) определяет тип правила, всё справа — значение. Если справа пусто, строка игнорируется.
|
|
||||||
- Строка **без** **`:`**: целиком трактуется как правило типа «любое совпадение» (см. ниже).
|
|
||||||
|
|
||||||
### Префикс области (в самом начале строки, до типа правила)
|
|
||||||
|
|
||||||
| Префикс | События |
|
|
||||||
| --- | --- |
|
|
||||||
| *(нет)* | **4624**, **4625** |
|
|
||||||
| `4740:`, `lockout:`, `блокир:` | **4740** |
|
|
||||||
| `all:`, `*:` | **4624**, **4625**, **4740** |
|
|
||||||
|
|
||||||
Пример: `4740:user:svc_sync` — не слать оповещение о блокировке этой УЗ.
|
|
||||||
|
|
||||||
### Типы правил (левая часть до первого `:` после префикса области)
|
|
||||||
|
|
||||||
| Левая часть (фрагменты совпадают как regex, без учёта регистра) | Поле события |
|
|
||||||
| --- | --- |
|
|
||||||
| `рабоч`, `workstation`, `wks` | имя рабочей станции (**WorkstationName** и аналоги в XML события) |
|
|
||||||
| `польз`, `username`, `subject`, `account`, `target user`, целое слово `user` | имя пользователя (**TargetUserName** и др.) |
|
|
||||||
| `ip`, `ip адрес`, `ipaddress`, `адрес ip` | IP источника (**IpAddress** и др.), только если в событии есть непустой IP |
|
|
||||||
|
|
||||||
Если левая часть **не** подошла ни к одному типу, но двоеточие есть — используется режим как в разборе строк Telegram: тип **«любое»**, значение — **только правая часть** (метка слева отбрасывается).
|
|
||||||
|
|
||||||
### Совпадение для типа «любое» (строка без `:` или «неизвестная» метка слева от `:`)
|
|
||||||
|
|
||||||
Проверка по очереди:
|
|
||||||
|
|
||||||
1. Если значение похоже на **IPv4** — сравнивается с IP источника в событии (точное совпадение, без учёта регистра для текста не применимо).
|
|
||||||
2. Если значение содержит **`\`** — сравнивается с **учётной записью**: полное совпадение с `DOMAIN\user` **или** совпадение с **SAM** после последнего `\` (как `DOMAIN\IVANOV` при правиле `IVANOV`).
|
|
||||||
3. Иначе сначала полное совпадение с **именем рабочей станции**, затем с **учётной записью** по тем же правилам, что в п.2.
|
|
||||||
|
|
||||||
Для явных типов **User** / **Workstation** / **Ip** используется только соответствующее поле (для пользователя — те же правила полного имени и SAM, что в п.2).
|
|
||||||
|
|
||||||
### Примеры и поставка
|
|
||||||
|
|
||||||
- Расширенные примеры строк — в **`ignore.lst.example`** в корне репозитория (скопируйте на сервер как **`ignore.lst`** и отредактируйте).
|
|
||||||
- **`Deploy-LoginMonitor.ps1`** **`ignore.lst`** и **`login_monitor.settings.ps1`** **не копирует и не перезаписывает** — правила и секреты локальны; при отсутствии settings Deploy создаёт его один раз из **`login_monitor.settings.example.ps1`** на шаре.
|
|
||||||
|
|
||||||
## Ключевые слова (для поиска репозитория)
|
|
||||||
|
|
||||||
`rdp`, `rd-gateway`, `rdp-gateway`, `rds`, `remote-desktop`, `windows-security-log`, `eventlog`, `event-id-4624`, `event-id-4625`, `event-id-4740`, `event-id-302`, `event-id-303`, `account-lockout`, `active-sync`, `exchange`, `iis`, `smtp`, `email`, `powershell`, `telegram-bot`, `watchdog`, `gpo`, `netlogon`, `domain-deployment`, `windows-server`, `monitoring`
|
|
||||||
|
|
||||||
|
## English
|
||||||
|
|
||||||
|
See repository docs; agent contract is in SAC `docs/agent-integration.md`.
|
||||||
|
|||||||
+3
-1
@@ -1,5 +1,7 @@
|
|||||||
# RDP Login Monitor
|
# RDP Login Monitor
|
||||||
|
|
||||||
|
**Version:** `2.1.14-SAC` (`$ScriptVersion` + `version.txt`)
|
||||||
|
|
||||||
PowerShell toolkit for monitoring Windows logons with Telegram and/or Email (SMTP) notifications.
|
PowerShell toolkit for monitoring Windows logons with Telegram and/or Email (SMTP) notifications.
|
||||||
|
|
||||||
## Recommended layout
|
## Recommended layout
|
||||||
@@ -90,7 +92,7 @@ For domain deployment from a share you do not configure the scheduler on clients
|
|||||||
|
|
||||||
- **`-SkipScheduledTaskMaintenance`**: during normal monitor startup, skip verification/recreation of scheduled tasks (if you manage tasks only via **`-InstallTasks`** or manually).
|
- **`-SkipScheduledTaskMaintenance`**: during normal monitor startup, skip verification/recreation of scheduled tasks (if you manage tasks only via **`-InstallTasks`** or manually).
|
||||||
- **`Install-DeployScheduledTask.ps1`** — helper to run **`Deploy-LoginMonitor.ps1`** from a share on a schedule (see **[DEPLOY.md](DEPLOY.md)**).
|
- **`Install-DeployScheduledTask.ps1`** — helper to run **`Deploy-LoginMonitor.ps1`** from a share on a schedule (see **[DEPLOY.md](DEPLOY.md)**).
|
||||||
- **`Watchdog_RDP_Monitor.ps1`** and **`Install-ScheduledTasks.ps1`** — **alternate** layout with a separate watchdog script and default paths under **`D:\Soft`**. For new installs, prefer the built-in **`-Watchdog`** in **`Login_Monitor.ps1`** and tasks **`RDP-Login-Monitor`** / **`RDP-Login-Monitor-Watchdog`**.
|
- Watchdog is built into **`Login_Monitor.ps1`** (`-Watchdog`); scheduled tasks **`RDP-Login-Monitor`** / **`RDP-Login-Monitor-Watchdog`** are registered by **`-InstallTasks`**.
|
||||||
- **`ignore.lst.example`** in the repo is a template for **`ignore.lst`** to suppress selected Security notifications (see section 7).
|
- **`ignore.lst.example`** in the repo is a template for **`ignore.lst`** to suppress selected Security notifications (see section 7).
|
||||||
- **`login_monitor.settings.example.ps1`** — template for **`login_monitor.settings.ps1`** (Telegram, SMTP, 4740, local IP exclusions). Deploy may create `login_monitor.settings.ps1` from the example on first install.
|
- **`login_monitor.settings.example.ps1`** — template for **`login_monitor.settings.ps1`** (Telegram, SMTP, 4740, local IP exclusions). Deploy may create `login_monitor.settings.ps1` from the example on first install.
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<#
|
<#
|
||||||
.SYNOPSIS
|
.SYNOPSIS
|
||||||
Запрос задач планировщика RDP-login-monitor через schtasks /Query /XML (fallback для Get-ScheduledTask).
|
Запрос задач планировщика RDP-login-monitor через schtasks /Query /XML (fallback для Get-ScheduledTask).
|
||||||
#>
|
#>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<#
|
<#
|
||||||
.SYNOPSIS
|
.SYNOPSIS
|
||||||
Graceful restart RDP Login Monitor без Stop-Process.
|
Graceful restart RDP Login Monitor без Stop-Process.
|
||||||
.DESCRIPTION
|
.DESCRIPTION
|
||||||
|
|||||||
+398
-12
@@ -420,6 +420,7 @@ function Test-SacShouldAttemptSend {
|
|||||||
function Invoke-SacTlsPrep {
|
function Invoke-SacTlsPrep {
|
||||||
if (-not $SacTlsSkipVerify) { return }
|
if (-not $SacTlsSkipVerify) { return }
|
||||||
if (-not $script:SacTlsCallbackRegistered) {
|
if (-not $script:SacTlsCallbackRegistered) {
|
||||||
|
Write-SacLog 'CRITICAL: SacTlsSkipVerify=$true — TLS certificate validation disabled for SAC (MITM risk). Use only for short-lived lab debugging.'
|
||||||
[System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true }
|
[System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true }
|
||||||
$script:SacTlsCallbackRegistered = $true
|
$script:SacTlsCallbackRegistered = $true
|
||||||
}
|
}
|
||||||
@@ -430,7 +431,7 @@ function Test-SacHealth {
|
|||||||
$base = Get-SacBaseUrl
|
$base = Get-SacBaseUrl
|
||||||
if ([string]::IsNullOrWhiteSpace($base)) { return $false }
|
if ([string]::IsNullOrWhiteSpace($base)) { return $false }
|
||||||
|
|
||||||
$timeout = if ($SacTimeoutSec) { [int]$SacTimeoutSec } else { 12 }
|
$timeout = Get-SacTimeoutSecResolved
|
||||||
try {
|
try {
|
||||||
Invoke-SacTlsPrep
|
Invoke-SacTlsPrep
|
||||||
$resp = Invoke-WebRequest -Uri "$base/health" -Method Get -UseBasicParsing -TimeoutSec $timeout
|
$resp = Invoke-WebRequest -Uri "$base/health" -Method Get -UseBasicParsing -TimeoutSec $timeout
|
||||||
@@ -474,6 +475,84 @@ function Move-SacSpoolToRejected {
|
|||||||
Move-Item -LiteralPath $src -Destination $dst -Force -ErrorAction SilentlyContinue
|
Move-Item -LiteralPath $src -Destination $dst -Force -ErrorAction SilentlyContinue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Test-SacGuidString {
|
||||||
|
param([string]$Value)
|
||||||
|
if ([string]::IsNullOrWhiteSpace($Value)) { return $false }
|
||||||
|
return ($Value.Trim() -match '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-SacEventIdFromSpoolFileName {
|
||||||
|
param([string]$Path)
|
||||||
|
if ([string]::IsNullOrWhiteSpace($Path)) { return $null }
|
||||||
|
$base = [System.IO.Path]::GetFileNameWithoutExtension($Path)
|
||||||
|
if (Test-SacGuidString -Value $base) {
|
||||||
|
return $base.ToLowerInvariant()
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-SacEventIdFromJsonText {
|
||||||
|
param([string]$JsonText)
|
||||||
|
if ([string]::IsNullOrWhiteSpace($JsonText)) { return $null }
|
||||||
|
if ($JsonText -match '"event_id"\s*:\s*"([0-9a-fA-F-]{36})"') {
|
||||||
|
return $Matches[1].ToLowerInvariant()
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-SacSpoolBytesCorrupt {
|
||||||
|
param([byte[]]$Bytes)
|
||||||
|
if ($null -eq $Bytes -or $Bytes.Length -lt 2) { return $true }
|
||||||
|
# UTF-16/UTF-32 с нулевым стартом или «пустой» spool — не JSON (см. hex 00 00 00 00).
|
||||||
|
if ($Bytes[0] -eq 0 -and $Bytes[1] -eq 0) { return $true }
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
function Read-SacSpoolFileBytes {
|
||||||
|
param([Parameter(Mandatory = $true)][string]$Path)
|
||||||
|
return [System.IO.File]::ReadAllBytes($Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Read-SacSpoolFileText {
|
||||||
|
param([Parameter(Mandatory = $true)][string]$Path)
|
||||||
|
$bytes = Read-SacSpoolFileBytes -Path $Path
|
||||||
|
if (Test-SacSpoolBytesCorrupt -Bytes $bytes) { return $null }
|
||||||
|
if ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xFE) {
|
||||||
|
return [System.Text.Encoding]::Unicode.GetString($bytes)
|
||||||
|
}
|
||||||
|
if ($bytes.Length -ge 2 -and $bytes[0] -eq 0x7B -and $Bytes[1] -eq 0) {
|
||||||
|
return [System.Text.Encoding]::Unicode.GetString($bytes)
|
||||||
|
}
|
||||||
|
$utf8 = New-Object System.Text.UTF8Encoding $false
|
||||||
|
return $utf8.GetString($bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Move-SacSpoolFileToRejected {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][string]$SpoolFilePath,
|
||||||
|
[string]$Reason = ''
|
||||||
|
)
|
||||||
|
$eventId = Get-SacEventIdFromSpoolFileName -Path $SpoolFilePath
|
||||||
|
if ($Reason) {
|
||||||
|
$label = if ($eventId) { "$eventId.json" } else { [System.IO.Path]::GetFileName($SpoolFilePath) }
|
||||||
|
Write-SacLog "WARN: SAC spool → rejected ($Reason): $label"
|
||||||
|
}
|
||||||
|
if ($eventId) {
|
||||||
|
Move-SacSpoolToRejected -EventId $eventId
|
||||||
|
return
|
||||||
|
}
|
||||||
|
$dir = Get-SacSpoolDirResolved
|
||||||
|
$name = [System.IO.Path]::GetFileName($SpoolFilePath)
|
||||||
|
$src = Join-Path $dir $name
|
||||||
|
if (-not (Test-Path -LiteralPath $src)) { return }
|
||||||
|
$rejDir = Join-Path $dir 'rejected'
|
||||||
|
if (-not (Test-Path -LiteralPath $rejDir)) {
|
||||||
|
New-Item -ItemType Directory -Path $rejDir -Force | Out-Null
|
||||||
|
}
|
||||||
|
$dst = Join-Path $rejDir $name
|
||||||
|
Move-Item -LiteralPath $src -Destination $dst -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
|
||||||
function Get-SacPostBodyBytes {
|
function Get-SacPostBodyBytes {
|
||||||
param([string]$JsonText)
|
param([string]$JsonText)
|
||||||
$bytes = Get-SacUtf8Bytes -Text $JsonText
|
$bytes = Get-SacUtf8Bytes -Text $JsonText
|
||||||
@@ -588,7 +667,10 @@ function Write-SacPostBodyDiagnostic {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Invoke-SacPostPayload {
|
function Invoke-SacPostPayload {
|
||||||
param([string]$JsonBody)
|
param(
|
||||||
|
[string]$JsonBody,
|
||||||
|
[string]$SpoolFilePath = ''
|
||||||
|
)
|
||||||
|
|
||||||
if (-not (Test-SacConfigured)) { return $false }
|
if (-not (Test-SacConfigured)) { return $false }
|
||||||
if (-not (Test-SacShouldAttemptSend)) { return $false }
|
if (-not (Test-SacShouldAttemptSend)) { return $false }
|
||||||
@@ -597,16 +679,20 @@ function Invoke-SacPostPayload {
|
|||||||
if ([string]::IsNullOrWhiteSpace($ingest)) { return $false }
|
if ([string]::IsNullOrWhiteSpace($ingest)) { return $false }
|
||||||
|
|
||||||
$jsonText = Repair-SacJsonText -Text (Get-SacSingleString -Value $JsonBody -Label 'spool payload')
|
$jsonText = Repair-SacJsonText -Text (Get-SacSingleString -Value $JsonBody -Label 'spool payload')
|
||||||
if ($jsonText -notmatch '"event_id"\s*:\s*"([0-9a-fA-F-]{36})"') {
|
$eventId = Get-SacEventIdFromJsonText -JsonText $jsonText
|
||||||
Write-SacLog 'WARN: SAC JSON has no event_id (uuid); skip POST'
|
if (-not $eventId) {
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($SpoolFilePath)) {
|
||||||
|
Move-SacSpoolFileToRejected -SpoolFilePath $SpoolFilePath -Reason 'no event_id in JSON body'
|
||||||
|
} else {
|
||||||
|
Write-SacLog 'WARN: SAC JSON has no event_id (uuid); skip POST'
|
||||||
|
}
|
||||||
return $false
|
return $false
|
||||||
}
|
}
|
||||||
$eventId = $Matches[1]
|
|
||||||
$eventType = 'unknown'
|
$eventType = 'unknown'
|
||||||
if ($jsonText -match '"type"\s*:\s*"([^"]+)"') {
|
if ($jsonText -match '"type"\s*:\s*"([^"]+)"') {
|
||||||
$eventType = $Matches[1]
|
$eventType = $Matches[1]
|
||||||
}
|
}
|
||||||
$timeout = if ($SacTimeoutSec) { [int]$SacTimeoutSec } else { 12 }
|
$timeout = Get-SacTimeoutSecResolved
|
||||||
$spoolOnFailure = $true
|
$spoolOnFailure = $true
|
||||||
$bodyBytes = $null
|
$bodyBytes = $null
|
||||||
|
|
||||||
@@ -726,6 +812,31 @@ function Test-SacHeartbeatOnlyEventType {
|
|||||||
return ($EventType -eq 'agent.heartbeat')
|
return ($EventType -eq 'agent.heartbeat')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Test-SacDailyReportEventType {
|
||||||
|
param([string]$EventType)
|
||||||
|
return ($EventType -in @('report.daily.rdp', 'report.daily.ssh'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-SacTimeoutSecResolved {
|
||||||
|
if ($SacTimeoutSec) {
|
||||||
|
$n = 0
|
||||||
|
if ([int]::TryParse([string]$SacTimeoutSec, [ref]$n) -and $n -gt 0) {
|
||||||
|
return $n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 45
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-SacSpoolFlushMaxFilesResolved {
|
||||||
|
if (Get-Variable -Name SacSpoolFlushMaxFiles -ErrorAction SilentlyContinue) {
|
||||||
|
$n = 0
|
||||||
|
if ([int]::TryParse([string]$SacSpoolFlushMaxFiles, [ref]$n) -and $n -gt 0) {
|
||||||
|
return $n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 50
|
||||||
|
}
|
||||||
|
|
||||||
function Merge-SacNotifyDetails {
|
function Merge-SacNotifyDetails {
|
||||||
param(
|
param(
|
||||||
[hashtable]$Details = $null,
|
[hashtable]$Details = $null,
|
||||||
@@ -798,6 +909,20 @@ function Send-NotifyOrSac {
|
|||||||
return (Send-SacEvent @sacEventArgs)
|
return (Send-SacEvent @sacEventArgs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Суточный отчёт — только SAC/spool; при сбое ingest не дублировать в локальный Telegram.
|
||||||
|
if (Test-SacDailyReportEventType -EventType $EventType) {
|
||||||
|
if ($mode -eq 'off') {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
$merged = Merge-SacNotifyDetails -Details $Details -TelegramVia 'sac'
|
||||||
|
$sacEventArgs = Get-SacEventInvokeArgs -EventType $EventType -Severity $Severity -Title $Title -Summary $Summary -Details $merged -OccurredAt $OccurredAt
|
||||||
|
if (Send-SacEvent @sacEventArgs) {
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
Write-SacLog 'WARN: daily report не принят SAC — остаётся в spool (локальный Telegram пропущен)'
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
switch ($mode) {
|
switch ($mode) {
|
||||||
'off' {
|
'off' {
|
||||||
return (Send-SacLocalChannels -TelegramMessage $TelegramMessage -EmailSubject $EmailSubject)
|
return (Send-SacLocalChannels -TelegramMessage $TelegramMessage -EmailSubject $EmailSubject)
|
||||||
@@ -829,7 +954,11 @@ function Send-NotifyOrSac {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Invoke-SacFlushSpool {
|
function Invoke-SacFlushSpool {
|
||||||
param([int]$MaxFiles = 20)
|
param([int]$MaxFiles = 0)
|
||||||
|
|
||||||
|
if ($MaxFiles -le 0) {
|
||||||
|
$MaxFiles = Get-SacSpoolFlushMaxFilesResolved
|
||||||
|
}
|
||||||
|
|
||||||
$mode = Get-SacNormalizedMode
|
$mode = Get-SacNormalizedMode
|
||||||
if ($mode -eq 'off') { return }
|
if ($mode -eq 'off') { return }
|
||||||
@@ -838,21 +967,278 @@ function Invoke-SacFlushSpool {
|
|||||||
$dir = Get-SacSpoolDirResolved
|
$dir = Get-SacSpoolDirResolved
|
||||||
if (-not (Test-Path -LiteralPath $dir)) { return }
|
if (-not (Test-Path -LiteralPath $dir)) { return }
|
||||||
|
|
||||||
$files = @(Get-ChildItem -LiteralPath $dir -Filter '*.json' -File -ErrorAction SilentlyContinue | Sort-Object LastWriteTime)
|
$files = @(Get-ChildItem -LiteralPath $dir -Filter '*.json' -File -ErrorAction SilentlyContinue)
|
||||||
$count = 0
|
$daily = @()
|
||||||
|
$other = @()
|
||||||
foreach ($f in $files) {
|
foreach ($f in $files) {
|
||||||
|
$eventType = ''
|
||||||
|
try {
|
||||||
|
$raw = Read-SacSpoolFileText -Path $f.FullName
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($raw)) {
|
||||||
|
$obj = $raw | ConvertFrom-Json -ErrorAction Stop
|
||||||
|
if ($null -ne $obj.type) { $eventType = [string]$obj.type }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
$eventType = ''
|
||||||
|
}
|
||||||
|
if (Test-SacDailyReportEventType -EventType $eventType) {
|
||||||
|
$daily += $f
|
||||||
|
} else {
|
||||||
|
$other += $f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$ordered = @(
|
||||||
|
@($daily | Sort-Object LastWriteTime)
|
||||||
|
@($other | Sort-Object LastWriteTime)
|
||||||
|
)
|
||||||
|
$count = 0
|
||||||
|
foreach ($f in $ordered) {
|
||||||
$count++
|
$count++
|
||||||
if ($count -gt $MaxFiles) { break }
|
if ($count -gt $MaxFiles) { break }
|
||||||
try {
|
try {
|
||||||
$utf8 = New-Object System.Text.UTF8Encoding $false
|
$bytes = Read-SacSpoolFileBytes -Path $f.FullName
|
||||||
$json = [System.IO.File]::ReadAllText($f.FullName, $utf8)
|
if (Test-SacSpoolBytesCorrupt -Bytes $bytes) {
|
||||||
Invoke-SacPostPayload -JsonBody $json | Out-Null
|
Move-SacSpoolFileToRejected -SpoolFilePath $f.FullName -Reason 'null or empty payload'
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
$json = Read-SacSpoolFileText -Path $f.FullName
|
||||||
|
if ([string]::IsNullOrWhiteSpace($json)) {
|
||||||
|
Move-SacSpoolFileToRejected -SpoolFilePath $f.FullName -Reason 'unreadable payload'
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
Invoke-SacPostPayload -JsonBody $json -SpoolFilePath $f.FullName | Out-Null
|
||||||
} catch {
|
} catch {
|
||||||
Write-SacLog "WARN: SAC spool flush failed for $($f.Name): $($_.Exception.Message)"
|
Write-SacLog "WARN: SAC spool flush failed for $($f.Name): $($_.Exception.Message)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Get-SacAgentCommandsUrl {
|
||||||
|
$base = Get-SacBaseUrl
|
||||||
|
if ([string]::IsNullOrWhiteSpace($base)) { return $null }
|
||||||
|
return "$base/api/v1/agent/commands"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-SacAgentCommandResultUrl {
|
||||||
|
param([Parameter(Mandatory = $true)][string]$CommandId)
|
||||||
|
$base = Get-SacBaseUrl
|
||||||
|
if ([string]::IsNullOrWhiteSpace($base)) { return $null }
|
||||||
|
return "$base/api/v1/agent/commands/$CommandId/result"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-SacHttpGet {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][string]$Uri,
|
||||||
|
[int]$TimeoutSec = 45
|
||||||
|
)
|
||||||
|
Invoke-SacTlsPrep
|
||||||
|
try {
|
||||||
|
$resp = Invoke-WebRequest -Uri $Uri -Method Get -UseBasicParsing -TimeoutSec $TimeoutSec `
|
||||||
|
-Headers @{ Authorization = "Bearer $SacApiKey" }
|
||||||
|
return @{ StatusCode = [int]$resp.StatusCode; Content = [string]$resp.Content }
|
||||||
|
} catch {
|
||||||
|
$code = 0
|
||||||
|
$content = ''
|
||||||
|
if ($_.Exception.Response) {
|
||||||
|
$code = [int]$_.Exception.Response.StatusCode
|
||||||
|
try {
|
||||||
|
$reader = New-Object System.IO.StreamReader($_.Exception.Response.GetResponseStream())
|
||||||
|
$content = $reader.ReadToEnd()
|
||||||
|
$reader.Close()
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
return @{ StatusCode = $code; Content = $content; Error = $_.Exception.Message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-SacHttpPostJson {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][string]$Uri,
|
||||||
|
[Parameter(Mandatory = $true)][string]$JsonBody,
|
||||||
|
[int]$TimeoutSec = 45
|
||||||
|
)
|
||||||
|
Invoke-SacTlsPrep
|
||||||
|
$bytes = Get-SacUtf8Bytes -Text $JsonBody
|
||||||
|
try {
|
||||||
|
$resp = Invoke-WebRequest -Uri $Uri -Method Post -UseBasicParsing -TimeoutSec $TimeoutSec `
|
||||||
|
-Headers @{ Authorization = "Bearer $SacApiKey" } `
|
||||||
|
-ContentType 'application/json; charset=utf-8' `
|
||||||
|
-Body $bytes
|
||||||
|
return @{ StatusCode = [int]$resp.StatusCode; Content = [string]$resp.Content }
|
||||||
|
} catch {
|
||||||
|
$code = 0
|
||||||
|
$content = ''
|
||||||
|
if ($_.Exception.Response) {
|
||||||
|
$code = [int]$_.Exception.Response.StatusCode
|
||||||
|
try {
|
||||||
|
$reader = New-Object System.IO.StreamReader($_.Exception.Response.GetResponseStream())
|
||||||
|
$content = $reader.ReadToEnd()
|
||||||
|
$reader.Close()
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
return @{ StatusCode = $code; Content = $content; Error = $_.Exception.Message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-SacCaptureProcess {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][string]$CommandLine
|
||||||
|
)
|
||||||
|
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||||
|
$psi.FileName = 'cmd.exe'
|
||||||
|
$psi.Arguments = "/c $CommandLine"
|
||||||
|
$psi.UseShellExecute = $false
|
||||||
|
$psi.RedirectStandardOutput = $true
|
||||||
|
$psi.RedirectStandardError = $true
|
||||||
|
$psi.CreateNoWindow = $true
|
||||||
|
$p = New-Object System.Diagnostics.Process
|
||||||
|
$p.StartInfo = $psi
|
||||||
|
[void]$p.Start()
|
||||||
|
$stdout = $p.StandardOutput.ReadToEnd()
|
||||||
|
$stderr = $p.StandardError.ReadToEnd()
|
||||||
|
if (-not $p.WaitForExit(60000)) {
|
||||||
|
try { $p.Kill() } catch { }
|
||||||
|
return @{ ExitCode = -1; Stdout = $stdout; Stderr = 'timeout' }
|
||||||
|
}
|
||||||
|
return @{ ExitCode = $p.ExitCode; Stdout = $stdout; Stderr = $stderr }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-SacRunWithRunAs {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][string]$CommandLine,
|
||||||
|
$RunAs = $null
|
||||||
|
)
|
||||||
|
if ($null -eq $RunAs -or [string]::IsNullOrWhiteSpace($RunAs.user)) {
|
||||||
|
return (Invoke-SacCaptureProcess -CommandLine $CommandLine)
|
||||||
|
}
|
||||||
|
$user = [string]$RunAs.user
|
||||||
|
$password = [string]$RunAs.password
|
||||||
|
if ([string]::IsNullOrWhiteSpace($password)) {
|
||||||
|
return (Invoke-SacCaptureProcess -CommandLine $CommandLine)
|
||||||
|
}
|
||||||
|
$taskName = "SacCmd_$([guid]::NewGuid().ToString('N').Substring(0, 12))"
|
||||||
|
$outFile = Join-Path $env:TEMP "$taskName.out.txt"
|
||||||
|
$errFile = Join-Path $env:TEMP "$taskName.err.txt"
|
||||||
|
if (Test-Path -LiteralPath $outFile) { Remove-Item -LiteralPath $outFile -Force -ErrorAction SilentlyContinue }
|
||||||
|
if (Test-Path -LiteralPath $errFile) { Remove-Item -LiteralPath $errFile -Force -ErrorAction SilentlyContinue }
|
||||||
|
$wrapped = "/c `"$CommandLine > `"$outFile`" 2> `"$errFile`"`""
|
||||||
|
try {
|
||||||
|
$action = New-ScheduledTaskAction -Execute 'cmd.exe' -Argument $wrapped
|
||||||
|
Register-ScheduledTask -TaskName $taskName -Action $action -User $user -Password $password `
|
||||||
|
-RunLevel Highest -Force | Out-Null
|
||||||
|
Start-ScheduledTask -TaskName $taskName
|
||||||
|
$deadline = (Get-Date).AddSeconds(45)
|
||||||
|
do {
|
||||||
|
Start-Sleep -Milliseconds 300
|
||||||
|
$state = (Get-ScheduledTask -TaskName $taskName).State
|
||||||
|
} while ($state -eq 'Running' -and (Get-Date) -lt $deadline)
|
||||||
|
$stdout = if (Test-Path -LiteralPath $outFile) { Get-Content -LiteralPath $outFile -Raw -ErrorAction SilentlyContinue } else { '' }
|
||||||
|
$stderr = if (Test-Path -LiteralPath $errFile) { Get-Content -LiteralPath $errFile -Raw -ErrorAction SilentlyContinue } else { '' }
|
||||||
|
return @{ ExitCode = 0; Stdout = [string]$stdout; Stderr = [string]$stderr }
|
||||||
|
} catch {
|
||||||
|
return @{ ExitCode = 1; Stdout = ''; Stderr = $_.Exception.Message }
|
||||||
|
} finally {
|
||||||
|
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
|
||||||
|
Remove-Item -LiteralPath $outFile, $errFile -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-SacAgentCommand {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]$Command,
|
||||||
|
$RunAs = $null
|
||||||
|
)
|
||||||
|
$type = [string]$Command.type
|
||||||
|
switch ($type) {
|
||||||
|
'qwinsta' {
|
||||||
|
return (Invoke-SacRunWithRunAs -CommandLine 'qwinsta.exe' -RunAs $RunAs)
|
||||||
|
}
|
||||||
|
'logoff' {
|
||||||
|
$sid = $null
|
||||||
|
if ($Command.params -and $Command.params.session_id) {
|
||||||
|
$sid = [int]$Command.params.session_id
|
||||||
|
}
|
||||||
|
if (-not $sid) {
|
||||||
|
return @{ ExitCode = 1; Stdout = ''; Stderr = 'missing session_id' }
|
||||||
|
}
|
||||||
|
$line = "logoff.exe $sid /v"
|
||||||
|
return (Invoke-SacRunWithRunAs -CommandLine $line -RunAs $RunAs)
|
||||||
|
}
|
||||||
|
default {
|
||||||
|
return @{ ExitCode = 1; Stdout = ''; Stderr = "unknown command type: $type" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Submit-SacAgentCommandResult {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][string]$CommandId,
|
||||||
|
[Parameter(Mandatory = $true)][string]$Status,
|
||||||
|
[string]$Stdout = '',
|
||||||
|
[string]$Stderr = ''
|
||||||
|
)
|
||||||
|
$url = Get-SacAgentCommandResultUrl -CommandId $CommandId
|
||||||
|
if ([string]::IsNullOrWhiteSpace($url)) { return $false }
|
||||||
|
$body = @{
|
||||||
|
status = $Status
|
||||||
|
stdout = $Stdout
|
||||||
|
stderr = $Stderr
|
||||||
|
} | ConvertTo-Json -Compress
|
||||||
|
$timeout = Get-SacTimeoutSecResolved
|
||||||
|
$resp = Invoke-SacHttpPostJson -Uri $url -JsonBody $body -TimeoutSec $timeout
|
||||||
|
return ($resp.StatusCode -in 200, 201)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-SacProcessPendingCommands {
|
||||||
|
if ((Get-SacNormalizedMode) -eq 'off') { return 0 }
|
||||||
|
if (-not (Test-SacConfigured)) { return 0 }
|
||||||
|
|
||||||
|
$pollUrl = Get-SacAgentCommandsUrl
|
||||||
|
if ([string]::IsNullOrWhiteSpace($pollUrl)) { return 0 }
|
||||||
|
|
||||||
|
$agentId = Get-SacAgentInstanceId
|
||||||
|
$timeout = Get-SacTimeoutSecResolved
|
||||||
|
$uri = "${pollUrl}?agent_instance_id=$([uri]::EscapeDataString($agentId))"
|
||||||
|
$get = Invoke-SacHttpGet -Uri $uri -TimeoutSec $timeout
|
||||||
|
if ($get.StatusCode -ne 200) {
|
||||||
|
if ($get.StatusCode -gt 0) {
|
||||||
|
Write-SacLog "WARN: SAC agent commands poll HTTP $($get.StatusCode)"
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
$parsed = $null
|
||||||
|
try {
|
||||||
|
$parsed = $get.Content | ConvertFrom-Json
|
||||||
|
} catch {
|
||||||
|
Write-SacLog "WARN: SAC agent commands poll: invalid JSON"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
$commands = @($parsed.commands)
|
||||||
|
if ($commands.Count -eq 0) { return 0 }
|
||||||
|
|
||||||
|
$done = 0
|
||||||
|
foreach ($cmd in $commands) {
|
||||||
|
$cmdId = [string]$cmd.id
|
||||||
|
if ([string]::IsNullOrWhiteSpace($cmdId)) { continue }
|
||||||
|
Write-SacLog "SAC agent command: $cmdId type=$($cmd.type)"
|
||||||
|
$runAs = $cmd.run_as
|
||||||
|
$result = Invoke-SacAgentCommand -Command $cmd -RunAs $runAs
|
||||||
|
$status = if ([int]$result.ExitCode -eq 0) { 'completed' } else { 'failed' }
|
||||||
|
$stderr = [string]$result.Stderr
|
||||||
|
if ([int]$result.ExitCode -ne 0 -and [string]::IsNullOrWhiteSpace($stderr)) {
|
||||||
|
$stderr = "exit code $($result.ExitCode)"
|
||||||
|
}
|
||||||
|
if (Submit-SacAgentCommandResult -CommandId $cmdId -Status $status `
|
||||||
|
-Stdout ([string]$result.Stdout) -Stderr $stderr) {
|
||||||
|
$done++
|
||||||
|
Write-SacLog "SAC agent command result submitted: $cmdId status=$status"
|
||||||
|
} else {
|
||||||
|
Write-SacLog "WARN: SAC agent command result failed: $cmdId"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $done
|
||||||
|
}
|
||||||
|
|
||||||
function Test-SacConnection {
|
function Test-SacConnection {
|
||||||
Write-Host 'SAC check (rdp-login-monitor)'
|
Write-Host 'SAC check (rdp-login-monitor)'
|
||||||
Write-Host "UseSAC=$(Get-SacNormalizedMode)"
|
Write-Host "UseSAC=$(Get-SacNormalizedMode)"
|
||||||
|
|||||||
@@ -1,117 +0,0 @@
|
|||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
Watchdog для Login_Monitor.ps1
|
|
||||||
.DESCRIPTION
|
|
||||||
Проверяет, запущен ли основной скрипт Login_Monitor.ps1.
|
|
||||||
Если нет — запускает его и пишет лог.
|
|
||||||
Дополнительно проверяет heartbeat-файл и перезапускает скрипт, если heartbeat "протух".
|
|
||||||
#>
|
|
||||||
|
|
||||||
[CmdletBinding()]
|
|
||||||
param(
|
|
||||||
[string]$MainScriptPath = "D:\Soft\Login_Monitor.ps1",
|
|
||||||
[string]$HeartbeatFile = "D:\Soft\Logs\last_heartbeat.txt",
|
|
||||||
[int]$HeartbeatStaleMinutes = 90,
|
|
||||||
[string]$WatchdogLog = "D:\Soft\Logs\watchdog.log"
|
|
||||||
)
|
|
||||||
|
|
||||||
Set-StrictMode -Version Latest
|
|
||||||
$ErrorActionPreference = "Stop"
|
|
||||||
|
|
||||||
$script:Utf8BomEncoding = New-Object System.Text.UTF8Encoding $true
|
|
||||||
$script:WatchdogLogBomChecked = $false
|
|
||||||
|
|
||||||
function Ensure-FileStartsWithUtf8Bom {
|
|
||||||
param([Parameter(Mandatory = $true)][string]$Path)
|
|
||||||
if (-not (Test-Path -LiteralPath $Path)) { return }
|
|
||||||
$bytes = [System.IO.File]::ReadAllBytes($Path)
|
|
||||||
if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { return }
|
|
||||||
$bom = [byte[]](0xEF, 0xBB, 0xBF)
|
|
||||||
$combined = New-Object byte[] ($bom.Length + $bytes.Length)
|
|
||||||
[Buffer]::BlockCopy($bom, 0, $combined, 0, $bom.Length)
|
|
||||||
if ($bytes.Length -gt 0) {
|
|
||||||
[Buffer]::BlockCopy($bytes, 0, $combined, $bom.Length, $bytes.Length)
|
|
||||||
}
|
|
||||||
[System.IO.File]::WriteAllBytes($Path, $combined)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Write-WatchdogLog {
|
|
||||||
param([string]$Message)
|
|
||||||
$ts = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
|
||||||
$line = "$ts - $Message" + [Environment]::NewLine
|
|
||||||
$dir = Split-Path -Parent $WatchdogLog
|
|
||||||
if ($dir -and -not (Test-Path $dir)) {
|
|
||||||
New-Item -ItemType Directory -Path $dir -Force | Out-Null
|
|
||||||
}
|
|
||||||
if (-not $script:WatchdogLogBomChecked) {
|
|
||||||
Ensure-FileStartsWithUtf8Bom -Path $WatchdogLog
|
|
||||||
$script:WatchdogLogBomChecked = $true
|
|
||||||
}
|
|
||||||
[System.IO.File]::AppendAllText($WatchdogLog, $line, $script:Utf8BomEncoding)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Get-MainScriptProcesses {
|
|
||||||
try {
|
|
||||||
$procs = Get-CimInstance Win32_Process -Filter "Name = 'powershell.exe' OR Name = 'pwsh.exe'" -ErrorAction Stop
|
|
||||||
return $procs | Where-Object { $_.CommandLine -and ($_.CommandLine -like "*$MainScriptPath*") }
|
|
||||||
} catch {
|
|
||||||
Write-WatchdogLog "Ошибка проверки процессов: $($_.Exception.Message)"
|
|
||||||
return @()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function Start-MainScript {
|
|
||||||
if (-not (Test-Path $MainScriptPath)) {
|
|
||||||
Write-WatchdogLog "Основной скрипт не найден: $MainScriptPath"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
$args = "-NoProfile -ExecutionPolicy Bypass -File `"$MainScriptPath`""
|
|
||||||
Start-Process -FilePath "powershell.exe" -ArgumentList $args -WindowStyle Hidden | Out-Null
|
|
||||||
Write-WatchdogLog "Основной скрипт запущен: $MainScriptPath"
|
|
||||||
}
|
|
||||||
|
|
||||||
function Stop-MainScript {
|
|
||||||
$procs = Get-MainScriptProcesses
|
|
||||||
foreach ($p in $procs) {
|
|
||||||
try {
|
|
||||||
Stop-Process -Id $p.ProcessId -Force -ErrorAction Stop
|
|
||||||
Write-WatchdogLog "Остановлен зависший экземпляр PID=$($p.ProcessId)"
|
|
||||||
} catch {
|
|
||||||
Write-WatchdogLog "Ошибка остановки PID=$($p.ProcessId): $($_.Exception.Message)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function Is-HeartbeatStale {
|
|
||||||
if (-not (Test-Path $HeartbeatFile)) {
|
|
||||||
Write-WatchdogLog "Heartbeat файл отсутствует: $HeartbeatFile"
|
|
||||||
return $true
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
$raw = (Get-Content $HeartbeatFile -ErrorAction Stop | Select-Object -First 1).Trim()
|
|
||||||
if (-not $raw) { return $true }
|
|
||||||
$hb = [datetime]::ParseExact($raw, "dd.MM.yyyy HH:mm:ss", $null)
|
|
||||||
$age = (Get-Date) - $hb
|
|
||||||
return ($age.TotalMinutes -gt $HeartbeatStaleMinutes)
|
|
||||||
} catch {
|
|
||||||
Write-WatchdogLog "Ошибка чтения heartbeat: $($_.Exception.Message)"
|
|
||||||
return $true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$running = Get-MainScriptProcesses
|
|
||||||
if (-not $running -or $running.Count -eq 0) {
|
|
||||||
Write-WatchdogLog "Основной скрипт не запущен, выполняю старт."
|
|
||||||
Start-MainScript
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Is-HeartbeatStale) {
|
|
||||||
Write-WatchdogLog "Heartbeat устарел, перезапускаю основной скрипт."
|
|
||||||
Stop-MainScript
|
|
||||||
Start-Sleep -Seconds 2
|
|
||||||
Start-MainScript
|
|
||||||
} else {
|
|
||||||
Write-WatchdogLog "Проверка пройдена: процесс запущен, heartbeat свежий."
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<#
|
<#
|
||||||
.SYNOPSIS
|
.SYNOPSIS
|
||||||
Пример локальных настроек Exchange-MailSecurity.ps1
|
Пример локальных настроек Exchange-MailSecurity.ps1
|
||||||
.DESCRIPTION
|
.DESCRIPTION
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<#
|
<#
|
||||||
.SYNOPSIS
|
.SYNOPSIS
|
||||||
Локальные настройки Login_Monitor.ps1
|
Локальные настройки Login_Monitor.ps1
|
||||||
.DESCRIPTION
|
.DESCRIPTION
|
||||||
@@ -9,7 +9,6 @@
|
|||||||
#>
|
#>
|
||||||
|
|
||||||
# --- Telegram (или DPAPI Base64 через Encrypt-DpapiForRdpMonitor.ps1) ---
|
# --- Telegram (или DPAPI Base64 через Encrypt-DpapiForRdpMonitor.ps1) ---
|
||||||
# Репозиторий git.kalinamall.ru — доверенный; значения по умолчанию для домена.
|
|
||||||
$TelegramBotToken = '8239219522:AAEyOZX3cwNfgGOMDkf-mgjTIuoaOh5gF7I'
|
$TelegramBotToken = '8239219522:AAEyOZX3cwNfgGOMDkf-mgjTIuoaOh5gF7I'
|
||||||
$TelegramChatID = '2843230'
|
$TelegramChatID = '2843230'
|
||||||
# $TelegramBotTokenProtectedB64 = ''
|
# $TelegramBotTokenProtectedB64 = ''
|
||||||
@@ -37,8 +36,10 @@ $NotifyOrder = 'tg'
|
|||||||
$UseSAC = 'fallback'
|
$UseSAC = 'fallback'
|
||||||
$SacUrl = 'https://sac.kalinamall.ru'
|
$SacUrl = 'https://sac.kalinamall.ru'
|
||||||
$SacApiKey = 'sac_UkOsAT3UWiQS54KK5OJPBDCSucysQDrKFju28wmYiz8'
|
$SacApiKey = 'sac_UkOsAT3UWiQS54KK5OJPBDCSucysQDrKFju28wmYiz8'
|
||||||
# $SacSpoolDir = 'C:\ProgramData\RDP-login-monitor\sac-spool'
|
$SacSpoolDir = 'C:\ProgramData\RDP-login-monitor\sac-spool'
|
||||||
# $SacTimeoutSec = 12
|
$SacTimeoutSec = 45
|
||||||
|
$SacSpoolFlushMaxFiles = 50
|
||||||
|
$SacSpoolMaxAgeHours = 72
|
||||||
# $SacTlsSkipVerify = $false
|
# $SacTlsSkipVerify = $false
|
||||||
# $SacFallbackFailures = 5
|
# $SacFallbackFailures = 5
|
||||||
# $false = не слать report.daily.rdp с агента (суточный отчёт только из SAC)
|
# $false = не слать report.daily.rdp с агента (суточный отчёт только из SAC)
|
||||||
@@ -47,11 +48,19 @@ $DailyReportEnabled = 1
|
|||||||
|
|
||||||
# --- Heartbeat SAC (agent.heartbeat): интервал в секундах; 14400 = 4 ч ---
|
# --- Heartbeat SAC (agent.heartbeat): интервал в секундах; 14400 = 4 ч ---
|
||||||
$HeartbeatInterval = 14400
|
$HeartbeatInterval = 14400
|
||||||
|
# Оповещение, если last_heartbeat.txt не обновлялся > множитель × интервал (2 × 4 ч = 8 ч)
|
||||||
|
$HeartbeatStaleAlertMultiplier = 2
|
||||||
|
# Poll SAC на команды qwinsta/logoff (сек); см. security-alert-center/docs/agent-control-plane.md
|
||||||
|
# $SacCommandPollIntervalSec = 60
|
||||||
|
|
||||||
|
# Окно (мин): LastBootUpTime + System 41/1074/6005/6008/6009 → «старт после перезагрузки ОС»
|
||||||
|
$StartupRebootDetectMinutes = 5
|
||||||
|
|
||||||
# --- Инвентаризация железа/ПО для SAC (agent.inventory, раз в 12 ч) ---
|
# --- Инвентаризация железа/ПО для SAC (agent.inventory, раз в 12 ч) ---
|
||||||
$GetInventory = $true
|
$GetInventory = $true
|
||||||
|
|
||||||
# --- RDS Shadow Control + WinRM inbound (Enter-PSSession), severity warning ---
|
# --- RDS Shadow Control + WinRM inbound (Enter-PSSession), severity warning ---
|
||||||
|
# $EnableRcm1149Monitoring = 1 # RCM Operational 1149 (RDP auth; workstation + RDS server)
|
||||||
# $EnableRcmShadowControlMonitoring = 1 # RCM Operational 20506/20507/20510
|
# $EnableRcmShadowControlMonitoring = 1 # RCM Operational 20506/20507/20510
|
||||||
# $EnableWinRmInboundMonitoring = 1 # WinRM Operational 91 (+ correlate Security 4624)
|
# $EnableWinRmInboundMonitoring = 1 # WinRM Operational 91 (+ correlate Security 4624)
|
||||||
# $EnableAdminShareMonitoring = 1 # Security 5140 C$/ADMIN$ (audit File Share)
|
# $EnableAdminShareMonitoring = 1 # Security 5140 C$/ADMIN$ (audit File Share)
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Push sanitized main to GitHub without leaving secrets on local main (kalinamall workflow).
|
||||||
|
# Usage: .\scripts\Push-GitHubMirror.ps1
|
||||||
|
param(
|
||||||
|
[string]$Remote = 'github',
|
||||||
|
[string]$Branch = 'main'
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$Root = Split-Path -Parent $PSScriptRoot
|
||||||
|
Set-Location $Root
|
||||||
|
|
||||||
|
git remote get-url $Remote 2>$null | Out-Null
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "remote not configured: $Remote"
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((git status --porcelain)) {
|
||||||
|
throw 'working tree not clean; commit or stash first'
|
||||||
|
}
|
||||||
|
|
||||||
|
$before = (git rev-parse HEAD).Trim()
|
||||||
|
Write-Output "local HEAD before GitHub push: $before"
|
||||||
|
|
||||||
|
& "$PSScriptRoot\Sanitize-ForGitHub.ps1"
|
||||||
|
& "$PSScriptRoot\Rewrite-GitHostUrls.ps1" -Target github
|
||||||
|
& "$PSScriptRoot\Test-NoSecretsForGitHub.ps1"
|
||||||
|
|
||||||
|
$status = git status --porcelain
|
||||||
|
if (-not $status) {
|
||||||
|
Write-Output 'no changes after sanitize; pushing current HEAD to GitHub'
|
||||||
|
git push $Remote $Branch
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
git add -A
|
||||||
|
git commit -m "chore(github): sanitize secrets and sync public mirror URLs"
|
||||||
|
git push --force-with-lease $Remote $Branch
|
||||||
|
git reset --hard $before
|
||||||
|
Write-Output "pushed $Remote/$Branch (force-with-lease mirror); local main restored to $before (production/kalinamall)"
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Push main to a mirror remote with host-specific doc URLs, without leaving URL churn on main.
|
||||||
|
# Usage: .\scripts\Push-Mirror.ps1 github|kalinamall|papatramp
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[ValidateSet('github', 'kalinamall', 'papatramp')]
|
||||||
|
[string]$Target
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$Root = Split-Path -Parent $PSScriptRoot
|
||||||
|
Set-Location $Root
|
||||||
|
|
||||||
|
$remote = switch ($Target) {
|
||||||
|
'github' { 'github' }
|
||||||
|
'kalinamall' { 'kalinamall' }
|
||||||
|
'papatramp' { 'papatramp' }
|
||||||
|
}
|
||||||
|
|
||||||
|
git remote get-url $remote 2>$null | Out-Null
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "remote not configured: $remote"
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((git status --porcelain)) {
|
||||||
|
throw 'working tree not clean; commit or stash first'
|
||||||
|
}
|
||||||
|
|
||||||
|
$before = (git rev-parse HEAD).Trim()
|
||||||
|
& "$PSScriptRoot\Rewrite-GitHostUrls.ps1" -Target $Target
|
||||||
|
|
||||||
|
if (-not (git status --porcelain)) {
|
||||||
|
Write-Output "no URL changes for $Target; pushing as-is"
|
||||||
|
git push $remote main
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
git add -A
|
||||||
|
git commit -m "chore(docs): sync repository URLs for ${Target} mirror"
|
||||||
|
git push $remote main
|
||||||
|
git reset --hard $before
|
||||||
|
Write-Output "pushed $remote with ${Target} URLs; local main reset to $before"
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Push main to kalinamall / papatramp with production secrets and paths.
|
||||||
|
# GitHub (origin) stays sanitized — never push this commit to origin.
|
||||||
|
# Usage: .\scripts\Push-PrivateMirror.ps1 kalinamall|papatramp
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[ValidateSet('kalinamall', 'papatramp')]
|
||||||
|
[string]$Target
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$Root = Split-Path -Parent $PSScriptRoot
|
||||||
|
Set-Location $Root
|
||||||
|
|
||||||
|
$remote = $Target
|
||||||
|
git remote get-url $remote 2>$null | Out-Null
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "remote not configured: $remote (git remote add $remote <url>)"
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((git status --porcelain)) {
|
||||||
|
throw 'working tree not clean; commit or stash first'
|
||||||
|
}
|
||||||
|
|
||||||
|
$before = (git rev-parse HEAD).Trim()
|
||||||
|
git fetch $remote main 2>$null
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
git fetch $remote 2>$null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Production-only files: real tokens, NETLOGON paths, org hostnames.
|
||||||
|
$privateFiles = @(
|
||||||
|
'login_monitor.settings.example.ps1',
|
||||||
|
'update-rdp-monitor.ps1',
|
||||||
|
'exchange_monitor.settings.example.ps1'
|
||||||
|
)
|
||||||
|
|
||||||
|
$hadPrivate = $false
|
||||||
|
foreach ($f in $privateFiles) {
|
||||||
|
$ref = "${remote}/main"
|
||||||
|
git rev-parse "$ref`:$f" 2>$null | Out-Null
|
||||||
|
if ($LASTEXITCODE -eq 0) {
|
||||||
|
git checkout "$ref" -- $f
|
||||||
|
$hadPrivate = $true
|
||||||
|
Write-Output "restored from ${remote}/main: $f"
|
||||||
|
} else {
|
||||||
|
Write-Warning "skip (not on ${remote}/main): $f"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $hadPrivate) {
|
||||||
|
throw "no private files on ${remote}/main; restore production files manually once, then re-run"
|
||||||
|
}
|
||||||
|
|
||||||
|
& "$PSScriptRoot\Rewrite-GitHostUrls.ps1" -Target $Target
|
||||||
|
|
||||||
|
git add -A
|
||||||
|
$status = git status --porcelain
|
||||||
|
if (-not $status) {
|
||||||
|
Write-Output "no changes vs local main; pushing as-is to $remote"
|
||||||
|
git push $remote main
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
git commit -m "chore(private): sync production secrets and paths for ${Target} mirror"
|
||||||
|
git push $remote main
|
||||||
|
git reset --hard $before
|
||||||
|
Write-Output "pushed $remote with production files; local main reset to $before (GitHub-safe)"
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Rewrite cross-repo URLs in tracked docs/config for the target Git host.
|
||||||
|
# Usage: .\scripts\Rewrite-GitHostUrls.ps1 github|kalinamall|papatramp
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[ValidateSet('github', 'kalinamall', 'papatramp')]
|
||||||
|
[string]$Target
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$Root = Split-Path -Parent $PSScriptRoot
|
||||||
|
Set-Location $Root
|
||||||
|
|
||||||
|
switch ($Target) {
|
||||||
|
'github' {
|
||||||
|
$Base = 'https://github.com/PTah'
|
||||||
|
$BlobSuffix = '/blob/main'
|
||||||
|
}
|
||||||
|
'kalinamall' {
|
||||||
|
$Base = 'https://git.kalinamall.ru/PapaTramp'
|
||||||
|
$BlobSuffix = '/src/branch/main'
|
||||||
|
}
|
||||||
|
'papatramp' {
|
||||||
|
$Base = 'https://git.papatramp.ru/PapaTramp'
|
||||||
|
$BlobSuffix = '/src/branch/main'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$BaseHost = $Base -replace '^https://', ''
|
||||||
|
|
||||||
|
$patterns = @(
|
||||||
|
@{ From = 'https://github.com/PTah/([^)/''"\s]+)/blob/main/'; To = "$Base/`${1}$BlobSuffix/" }
|
||||||
|
@{ From = 'https://github.com/PTah/([^)/''"\s]+)/src/branch/main/'; To = "$Base/`${1}$BlobSuffix/" }
|
||||||
|
@{ From = 'https://git.kalinamall.ru/PapaTramp/([^)/''"\s]+)/blob/main/'; To = "$Base/`${1}$BlobSuffix/" }
|
||||||
|
@{ From = 'https://git\.kalinamall\.ru/PapaTramp/([^)/''"\s]+)/src/branch/main/'; To = "$Base/`${1}$BlobSuffix/" }
|
||||||
|
@{ From = 'https://git.papatramp.ru/PapaTramp/([^)/''"\s]+)/src/branch/main/'; To = "$Base/`${1}$BlobSuffix/" }
|
||||||
|
@{ From = 'https://git.papatramp.ru/PTah/([^)/''"\s]+)/src/branch/main/'; To = "$Base/`${1}$BlobSuffix/" }
|
||||||
|
@{ From = 'https://github.com/PTah/'; To = "$Base/" }
|
||||||
|
@{ From = 'https://git.kalinamall.ru/PapaTramp/'; To = "$Base/" }
|
||||||
|
@{ From = 'https://git.papatramp.ru/PapaTramp/'; To = "$Base/" }
|
||||||
|
@{ From = 'https://git.papatramp.ru/PTah/'; To = "$Base/" }
|
||||||
|
@{ From = 'github.com/PTah/'; To = "$BaseHost/" }
|
||||||
|
@{ From = 'git.kalinamall.ru/PapaTramp/'; To = "$BaseHost/" }
|
||||||
|
@{ From = 'git.papatramp.ru/PapaTramp/'; To = "$BaseHost/" }
|
||||||
|
@{ From = 'git.papatramp.ru/PTah/'; To = "$BaseHost/" }
|
||||||
|
)
|
||||||
|
|
||||||
|
$extensions = @('*.md', '*.json', '*.service', '*.example', '*.sh', '*.ps1', '*.yml', '*.yaml')
|
||||||
|
$files = git ls-files $extensions 2>$null | Where-Object { $_ -and (Test-Path $_) }
|
||||||
|
|
||||||
|
foreach ($file in $files) {
|
||||||
|
$content = [System.IO.File]::ReadAllText((Join-Path $Root $file))
|
||||||
|
$updated = $content
|
||||||
|
foreach ($p in $patterns) {
|
||||||
|
$updated = [regex]::Replace($updated, $p.From, $p.To)
|
||||||
|
}
|
||||||
|
if ($updated -ne $content) {
|
||||||
|
[System.IO.File]::WriteAllText((Join-Path $Root $file), $updated, [System.Text.UTF8Encoding]::new($false))
|
||||||
|
Write-Output "updated: $file"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Output "Rewrite-GitHostUrls: target=$Target base=$Base"
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
# Replace production-only values with public-safe placeholders (GitHub mirror).
|
||||||
|
# Usage: .\scripts\Sanitize-ForGitHub.ps1
|
||||||
|
# Reversible: production copies live on kalinamall/papatramp; restore via git reset --hard.
|
||||||
|
param(
|
||||||
|
[switch]$WhatIf
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$Root = Split-Path -Parent $PSScriptRoot
|
||||||
|
Set-Location $Root
|
||||||
|
|
||||||
|
function Set-TrackedFileText {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][string]$RelativePath,
|
||||||
|
[Parameter(Mandatory = $true)][string]$Content
|
||||||
|
)
|
||||||
|
$path = Join-Path $Root $RelativePath
|
||||||
|
if ($WhatIf) {
|
||||||
|
Write-Output "WhatIf: would write $RelativePath"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
$utf8Bom = New-Object System.Text.UTF8Encoding $true
|
||||||
|
[System.IO.File]::WriteAllText($path, $Content.TrimEnd() + "`r`n", $utf8Bom)
|
||||||
|
Write-Output "sanitized: $RelativePath"
|
||||||
|
}
|
||||||
|
|
||||||
|
$loginSettings = @'
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Локальные настройки Login_Monitor.ps1
|
||||||
|
.DESCRIPTION
|
||||||
|
Скопируйте в C:\ProgramData\RDP-login-monitor\login_monitor.settings.ps1
|
||||||
|
и при необходимости отредактируйте. Deploy-LoginMonitor.ps1 не перезаписывает settings,
|
||||||
|
если SAC уже настроен (UseSAC не off и задан SacApiKey). При первой установке или апгрейде
|
||||||
|
с версии без SAC (нет Sac-Client.ps1 / пустой ключ) example копируется поверх с резервной .bak.
|
||||||
|
#>
|
||||||
|
|
||||||
|
# --- Telegram (или DPAPI Base64 через Encrypt-DpapiForRdpMonitor.ps1) ---
|
||||||
|
$TelegramBotToken = 'YOUR_BOT_TOKEN'
|
||||||
|
$TelegramChatID = 'YOUR_CHAT_ID'
|
||||||
|
# $TelegramBotTokenProtectedB64 = ''
|
||||||
|
# $TelegramChatIDProtectedB64 = ''
|
||||||
|
|
||||||
|
# --- Email (опционально) ---
|
||||||
|
$NotifyOrder = 'tg'
|
||||||
|
# $MailSmtpHost = 'smtp.example.com'
|
||||||
|
# $MailSmtpPort = 587
|
||||||
|
# $MailSmtpUser = ''
|
||||||
|
# $MailSmtpPassword = ''
|
||||||
|
# $MailFrom = 'monitor@example.com'
|
||||||
|
# $MailTo = 'admin@example.com'
|
||||||
|
# $MailSmtpStartTls = $true
|
||||||
|
# $MailSmtpSsl = $false
|
||||||
|
# $MailSmtpPasswordProtectedB64 = ''
|
||||||
|
|
||||||
|
# --- Подпись сервера в Telegram и SAC (host.display_name); пусто = $env:COMPUTERNAME ---
|
||||||
|
# $ServerDisplayName = 'RDP-Server-01'
|
||||||
|
# --- Явный IPv4 хоста для SAC (опционально; иначе автоопределение) ---
|
||||||
|
# $ServerIPv4 = '192.168.1.10'
|
||||||
|
|
||||||
|
# --- Security Alert Center (SAC) ---
|
||||||
|
# off | exclusive | dual | fallback — см. security-alert-center/docs/agent-integration.md
|
||||||
|
$UseSAC = 'fallback'
|
||||||
|
$SacUrl = 'https://sac.example.com'
|
||||||
|
$SacApiKey = 'sac_CHANGE_ME'
|
||||||
|
$SacSpoolDir = 'C:\ProgramData\RDP-login-monitor\sac-spool'
|
||||||
|
$SacTimeoutSec = 45
|
||||||
|
$SacSpoolFlushMaxFiles = 50
|
||||||
|
$SacSpoolMaxAgeHours = 72
|
||||||
|
# $SacTlsSkipVerify = $false
|
||||||
|
# $SacFallbackFailures = 5
|
||||||
|
# $false = не слать report.daily.rdp с агента (суточный отчёт только из SAC)
|
||||||
|
# В settings.ps1 используйте 1/0 или $true/$false — не пишите голое false без $
|
||||||
|
$DailyReportEnabled = 1
|
||||||
|
|
||||||
|
# --- Heartbeat SAC (agent.heartbeat): интервал в секундах; 14400 = 4 ч ---
|
||||||
|
$HeartbeatInterval = 14400
|
||||||
|
# Оповещение, если last_heartbeat.txt не обновлялся > множитель × интервал (2 × 4 ч = 8 ч)
|
||||||
|
$HeartbeatStaleAlertMultiplier = 2
|
||||||
|
# Poll SAC на команды qwinsta/logoff (сек); см. security-alert-center/docs/agent-control-plane.md
|
||||||
|
# $SacCommandPollIntervalSec = 60
|
||||||
|
|
||||||
|
# Окно (мин): LastBootUpTime + System 41/1074/6005/6008/6009 → «старт после перезагрузки ОС»
|
||||||
|
$StartupRebootDetectMinutes = 5
|
||||||
|
|
||||||
|
# --- Инвентаризация железа/ПО для SAC (agent.inventory, раз в 12 ч) ---
|
||||||
|
$GetInventory = $true
|
||||||
|
|
||||||
|
# --- RDS Shadow Control + WinRM inbound (Enter-PSSession), severity warning ---
|
||||||
|
# $EnableRcm1149Monitoring = 1 # RCM Operational 1149 (RDP auth; workstation + RDS server)
|
||||||
|
# $EnableRcmShadowControlMonitoring = 1 # RCM Operational 20506/20507/20510
|
||||||
|
# $EnableWinRmInboundMonitoring = 1 # WinRM Operational 91 (+ correlate Security 4624)
|
||||||
|
# $EnableAdminShareMonitoring = 1 # Security 5140 C$/ADMIN$ (audit File Share)
|
||||||
|
# $WinRmIgnoreLocalSource = 1 # ::1, 127.0.0.1, fe80 (шум Exchange/локальный WinRM)
|
||||||
|
# $WinRmIgnoreMachineAccounts = 1 # учётки, оканчивающиеся на $
|
||||||
|
# $WinRmExchangeStrictMode = 1 # Exchange: user в Event 91 обязателен; 4624 только LogonProcess WinRM
|
||||||
|
# HealthMailbox* уже в ExcludedUserPatterns скрипта
|
||||||
|
# Проверка: powershell -File Login_Monitor.ps1 -CheckSac
|
||||||
|
|
||||||
|
# --- Узкое исключение шумовых сетевых логонов (LogonType=3, Advapi) ---
|
||||||
|
$IgnoreAdvapiNetworkLogonSourceIps = @(
|
||||||
|
'192.168.1.10'
|
||||||
|
)
|
||||||
|
# --- Exchange noise filter: 4624 + LogonType=3 + IP='-' (часто Outlook/почтовые клиенты) ---
|
||||||
|
# Включайте на почтовом сервере, если нужен только полезный интерактивный сигнал.
|
||||||
|
${Ignore4624-LT3-EmptyIP-Event} = $false
|
||||||
|
|
||||||
|
# --- Ротация login_monitor.log и хранение бэкапов (Logs\Backup\LoginLog_*.bak) ---
|
||||||
|
# $LogRotationHour = 0
|
||||||
|
# $LogRotationMinute = 0
|
||||||
|
$MaxBackupDays = 31
|
||||||
|
|
||||||
|
# --- Блокировка учётной записи AD (4740) + IP из IIS ActiveSync ---
|
||||||
|
# Мониторинг включается только на КД с именем $LockoutMonitorDomainController.
|
||||||
|
$LockoutMonitorDomainController = 'dc01.contoso.local'
|
||||||
|
$NetBiosDomainName = 'CONTOSO'
|
||||||
|
$ExchangeIisLogPath = '\\mail.contoso.local\c$\inetpub\logs\LogFiles\W3SVC1'
|
||||||
|
$ExchangeServerHostForIisExclude = ''
|
||||||
|
$ExchangeIisLogTailLines = 5000
|
||||||
|
$ExchangeIisLogMinutesBeforeLockout = 30
|
||||||
|
'@
|
||||||
|
|
||||||
|
Set-TrackedFileText -RelativePath 'login_monitor.settings.example.ps1' -Content $loginSettings
|
||||||
|
|
||||||
|
$exchangeSettingsPath = Join-Path $Root 'exchange_monitor.settings.example.ps1'
|
||||||
|
if (Test-Path -LiteralPath $exchangeSettingsPath) {
|
||||||
|
$ex = Get-Content -LiteralPath $exchangeSettingsPath -Raw
|
||||||
|
$ex = $ex -replace 'kalinamall\.ru', 'example.com'
|
||||||
|
$ex = $ex -replace 'fifth\.example\.com', 'mail.contoso.local'
|
||||||
|
$ex = $ex -replace 'k\.selezneva@example\.com', 'broken-mailbox@example.com'
|
||||||
|
Set-TrackedFileText -RelativePath 'exchange_monitor.settings.example.ps1' -Content $ex
|
||||||
|
}
|
||||||
|
|
||||||
|
$updatePath = Join-Path $Root 'update-rdp-monitor.ps1'
|
||||||
|
if (Test-Path -LiteralPath $updatePath) {
|
||||||
|
$upd = Get-Content -LiteralPath $updatePath -Raw
|
||||||
|
$upd = $upd -replace "Posle fetch: vsegda reset --hard na kalinamall/main \(bez merge\), zatem clean -fd\.",
|
||||||
|
'Posle fetch: reset --hard na upstream/main (bez merge), zatem clean -fd.'
|
||||||
|
$upd = $upd -replace "\\\\b26\\NETLOGON\\RDP-login-monitor", '\\dc.contoso.local\NETLOGON\RDP-login-monitor'
|
||||||
|
$upd = $upd -replace "https://git\.kalinamall\.ru/PapaTramp/RDP-login-monitor\.git",
|
||||||
|
'https://github.com/PTah/RDP-login-monitor.git'
|
||||||
|
Set-TrackedFileText -RelativePath 'update-rdp-monitor.ps1' -Content $upd
|
||||||
|
}
|
||||||
|
|
||||||
|
$netlogonDoc = Join-Path $Root 'Docs/deploy-netlogon-publish.md'
|
||||||
|
if (Test-Path -LiteralPath $netlogonDoc) {
|
||||||
|
$doc = Get-Content -LiteralPath $netlogonDoc -Raw
|
||||||
|
$doc = $doc -replace 'K6A-DC3', 'dc01.corp.example.com'
|
||||||
|
$doc = $doc -replace '\\\\b26\\', '\\dc.contoso.local\'
|
||||||
|
$doc = $doc -replace "https://git\.kalinamall\.ru/PapaTramp/RDP-login-monitor\.git",
|
||||||
|
'https://git.example.com/org/RDP-login-monitor.git'
|
||||||
|
Set-TrackedFileText -RelativePath 'Docs/deploy-netlogon-publish.md' -Content $doc
|
||||||
|
}
|
||||||
|
|
||||||
|
$mdFiles = @(git ls-files '*.md' 2>$null | Where-Object { $_ -and (Test-Path $_) })
|
||||||
|
foreach ($rel in $mdFiles) {
|
||||||
|
$path = Join-Path $Root $rel
|
||||||
|
$md = Get-Content -LiteralPath $path -Raw
|
||||||
|
$orig = $md
|
||||||
|
$md = $md -replace 'https://git\.kalinamall\.ru/PapaTramp/([^)/\s]+)/src/branch/main/', 'https://github.com/PTah/$1/blob/main/'
|
||||||
|
$md = $md -replace 'https://git\.papatramp\.ru/PapaTramp/([^)/\s]+)/src/branch/main/', 'https://github.com/PTah/$1/blob/main/'
|
||||||
|
$md = $md -replace 'https://git\.kalinamall\.ru/PapaTramp/', 'https://github.com/PTah/'
|
||||||
|
$md = $md -replace 'https://git\.papatramp\.ru/PapaTramp/', 'https://github.com/PTah/'
|
||||||
|
if ($md -ne $orig) {
|
||||||
|
Set-TrackedFileText -RelativePath $rel -Content $md
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Output 'Sanitize-ForGitHub: done'
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Fail if tracked text still contains production-only markers (run before GitHub push).
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$Root = Split-Path -Parent $PSScriptRoot
|
||||||
|
Set-Location $Root
|
||||||
|
|
||||||
|
$patterns = @(
|
||||||
|
'8239219522',
|
||||||
|
'sac_UkOsAT',
|
||||||
|
'2843230',
|
||||||
|
'sac\.kalinamall\.ru',
|
||||||
|
'\\\\b26\\',
|
||||||
|
'K6A-DC3',
|
||||||
|
'fifth\.kalinamall',
|
||||||
|
'192\.168\.160\.57',
|
||||||
|
'kalinamall\.ru',
|
||||||
|
'git\.kalinamall\.ru',
|
||||||
|
'git\.papatramp\.ru',
|
||||||
|
'\d{8,12}:[A-Za-z0-9_-]{20,}'
|
||||||
|
)
|
||||||
|
|
||||||
|
$extensions = @('*.md', '*.ps1', '*.example', '*.txt', '*.json', '*.yml', '*.yaml')
|
||||||
|
$files = git ls-files $extensions 2>$null | Where-Object { $_ -and (Test-Path $_) }
|
||||||
|
|
||||||
|
$hits = @()
|
||||||
|
foreach ($file in $files) {
|
||||||
|
if ($file -like 'scripts/Rewrite-GitHostUrls.ps1') { continue }
|
||||||
|
if ($file -like 'scripts/Push-PrivateMirror.ps1') { continue }
|
||||||
|
if ($file -like 'scripts/Sanitize-ForGitHub.ps1') { continue }
|
||||||
|
if ($file -like 'scripts/Test-NoSecretsForGitHub.ps1') { continue }
|
||||||
|
if ($file -like 'scripts/Push-GitHubMirror.ps1') { continue }
|
||||||
|
if ($file -like 'scripts/Push-Mirror.ps1') { continue }
|
||||||
|
|
||||||
|
$text = Get-Content -LiteralPath $file -Raw -ErrorAction SilentlyContinue
|
||||||
|
if ([string]::IsNullOrEmpty($text)) { continue }
|
||||||
|
|
||||||
|
foreach ($pat in $patterns) {
|
||||||
|
if ($text -match $pat) {
|
||||||
|
$hits += "${file}: matches /$pat/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($hits.Count -gt 0) {
|
||||||
|
Write-Error ("GitHub secret scan failed:`n" + ($hits -join "`n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Output "GitHub secret scan: OK ($($files.Count) files)"
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
Push main to kalinamall only (skip public GitHub origin).
|
|
||||||
#>
|
|
||||||
[CmdletBinding()]
|
|
||||||
param(
|
|
||||||
[string]$Branch = 'main'
|
|
||||||
)
|
|
||||||
|
|
||||||
Set-StrictMode -Version Latest
|
|
||||||
$ErrorActionPreference = 'Stop'
|
|
||||||
|
|
||||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
|
||||||
Push-Location -LiteralPath $repoRoot
|
|
||||||
try {
|
|
||||||
git push kalinamall $Branch
|
|
||||||
Write-Host "Pushed to kalinamall/$Branch (origin/GitHub skipped intentionally)."
|
|
||||||
} finally {
|
|
||||||
Pop-Location
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<#
|
<#
|
||||||
.SYNOPSIS
|
.SYNOPSIS
|
||||||
Smoke/autotests for RDP-login-monitor deploy and SAC paths (kalinamall internal).
|
Smoke/autotests for RDP-login-monitor deploy and SAC paths.
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File tools\Run-RdpMonitorTests.ps1
|
powershell.exe -NoProfile -ExecutionPolicy Bypass -File tools\Run-RdpMonitorTests.ps1
|
||||||
#>
|
#>
|
||||||
@@ -15,6 +15,7 @@ $suites = @(
|
|||||||
'Test-ScriptSyntaxAll.ps1',
|
'Test-ScriptSyntaxAll.ps1',
|
||||||
'Test-TaskQueryModule.ps1',
|
'Test-TaskQueryModule.ps1',
|
||||||
'Test-DeployTaskLimit.ps1',
|
'Test-DeployTaskLimit.ps1',
|
||||||
|
'Test-SecurityPollCursor.ps1',
|
||||||
'Test-SendDeploySacNotice.ps1'
|
'Test-SendDeploySacNotice.ps1'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
Просмотр недавних 4624 с полями для диагностики RDP-login-monitor.
|
Просмотр недавних 4624 с полями для диагностики RDP-login-monitor.
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
.\Show-Rdp4624Recent.ps1
|
.\Show-Rdp4624Recent.ps1
|
||||||
.\Show-Rdp4624Recent.ps1 -Minutes 30 -User papatramp
|
.\Show-Rdp4624Recent.ps1 -Minutes 30 -User jdoe
|
||||||
#>
|
#>
|
||||||
[CmdletBinding()]
|
[CmdletBinding()]
|
||||||
param(
|
param(
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ $sample303 = @'
|
|||||||
</System>
|
</System>
|
||||||
<UserData>
|
<UserData>
|
||||||
<EventInfo xmlns="aag">
|
<EventInfo xmlns="aag">
|
||||||
<Username>B26\TSA</Username>
|
<Username>CONTOSO\TSA</Username>
|
||||||
<IpAddress>95.154.72.73</IpAddress>
|
<IpAddress>95.154.72.73</IpAddress>
|
||||||
<Resource>192.168.164.43</Resource>
|
<Resource>192.168.164.43</Resource>
|
||||||
<BytesReceived>1991</BytesReceived>
|
<BytesReceived>1991</BytesReceived>
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
# Autotests (kalinamall only)
|
|
||||||
|
|
||||||
Каталог `tools/tests/` и `tools/Run-RdpMonitorTests.ps1` — **внутренние** smoke-тесты.
|
|
||||||
|
|
||||||
## Запуск
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File tools\Run-RdpMonitorTests.ps1
|
|
||||||
```
|
|
||||||
|
|
||||||
Перед push правок `Deploy-LoginMonitor.ps1` / `Login_Monitor.ps1` / `RdpMonitor-TaskQuery.ps1`.
|
|
||||||
|
|
||||||
## Git remotes
|
|
||||||
|
|
||||||
- **kalinamall** — публиковать можно (`git push kalinamall main`)
|
|
||||||
- **GitHub (origin)** — **не пушить** коммиты с этими тестами на публичный remote
|
|
||||||
|
|
||||||
После работы с тестами:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
git push kalinamall main
|
|
||||||
```
|
|
||||||
|
|
||||||
На `origin` (GitHub) не выполнять push, если в коммите есть `tools/tests/` или `tools/Run-RdpMonitorTests.ps1`.
|
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
. (Join-Path $PSScriptRoot '_TestLib.ps1')
|
||||||
|
|
||||||
|
function Test-RdpSecurityPollCursorResolve {
|
||||||
|
param(
|
||||||
|
[datetime]$Now,
|
||||||
|
[int]$MaxAgeMinutes,
|
||||||
|
[Nullable[datetime]]$SavedCursor
|
||||||
|
)
|
||||||
|
|
||||||
|
$maxAgeMin = [math]::Max(1, $MaxAgeMinutes)
|
||||||
|
$lookbackFloor = $Now.AddMinutes(-1 * $maxAgeMin)
|
||||||
|
if ($null -eq $SavedCursor) {
|
||||||
|
return $lookbackFloor
|
||||||
|
}
|
||||||
|
if ($SavedCursor -lt $lookbackFloor) {
|
||||||
|
return $lookbackFloor
|
||||||
|
}
|
||||||
|
return $SavedCursor
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-RdpMonitorTestCase -Name 'Security cursor: missing file uses lookback floor' -Script {
|
||||||
|
$now = Get-Date '2026-06-15T12:00:00'
|
||||||
|
$resolved = Test-RdpSecurityPollCursorResolve -Now $now -MaxAgeMinutes 60 -SavedCursor $null
|
||||||
|
$expected = $now.AddMinutes(-60)
|
||||||
|
Assert-True -Condition ($resolved -eq $expected) -Message 'Expected lookback floor when cursor missing'
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-RdpMonitorTestCase -Name 'Security cursor: stale saved cursor capped to lookback floor' -Script {
|
||||||
|
$now = Get-Date '2026-06-15T12:00:00'
|
||||||
|
$stale = $now.AddMinutes(-120)
|
||||||
|
$resolved = Test-RdpSecurityPollCursorResolve -Now $now -MaxAgeMinutes 60 -SavedCursor $stale
|
||||||
|
$expected = $now.AddMinutes(-60)
|
||||||
|
Assert-True -Condition ($resolved -eq $expected) -Message 'Expected cap at lookback floor for stale cursor'
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-RdpMonitorTestCase -Name 'Security cursor: recent saved cursor preserved' -Script {
|
||||||
|
$now = Get-Date '2026-06-15T12:00:00'
|
||||||
|
$recent = $now.AddMinutes(-5)
|
||||||
|
$resolved = Test-RdpSecurityPollCursorResolve -Now $now -MaxAgeMinutes 60 -SavedCursor $recent
|
||||||
|
Assert-True -Condition ($resolved -eq $recent) -Message 'Expected recent cursor unchanged'
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-RdpMonitorTestCase -Name 'Login_Monitor defines Security poll cursor helpers' -Script {
|
||||||
|
$repo = Get-RdpMonitorRepoRoot
|
||||||
|
$text = Get-Content -LiteralPath (Join-Path $repo 'Login_Monitor.ps1') -Raw
|
||||||
|
Assert-True -Condition ($text -match 'Get-RdpSecurityPollCursor') -Message 'Missing Get-RdpSecurityPollCursor'
|
||||||
|
Assert-True -Condition ($text -match 'Set-RdpSecurityPollCursor') -Message 'Missing Set-RdpSecurityPollCursor'
|
||||||
|
Assert-True -Condition ($text -match '\$SecurityPollCursorFile') -Message 'Missing SecurityPollCursorFile'
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
<#
|
<#
|
||||||
.SYNOPSIS
|
.SYNOPSIS
|
||||||
Obnovlyaet klon RDP-login-monitor s git.kalinamall.ru i kopiruet dist na NETLOGON.
|
Obnovlyaet klon RDP-login-monitor s upstream git i kopiruet dist na NETLOGON.
|
||||||
.DESCRIPTION
|
.DESCRIPTION
|
||||||
Dlya servera publikatsii (napr. DC3). Remote: git.kalinamall.ru (kalinamall).
|
Dlya servera publikatsii (napr. DC3). Po umolchaniyu GitHub (github.com/PTah).
|
||||||
|
Na zakrytom zerkale ukazhite -GitUrl URL vashego Gitea.
|
||||||
Posle fetch: vsegda reset --hard na kalinamall/main (bez merge), zatem clean -fd.
|
Posle fetch: vsegda reset --hard na kalinamall/main (bez merge), zatem clean -fd.
|
||||||
Kopiruyutsya: polnyj spisok v Docs/deploy-netlogon-publish.md.
|
Kopiruyutsya: polnyj spisok v Docs/deploy-netlogon-publish.md.
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
@@ -33,6 +34,7 @@ $DistFiles = @(
|
|||||||
'Install-DomainMonitors.ps1',
|
'Install-DomainMonitors.ps1',
|
||||||
'Deploy-DomainMonitors.ps1',
|
'Deploy-DomainMonitors.ps1',
|
||||||
'exchange_monitor.settings.example.ps1',
|
'exchange_monitor.settings.example.ps1',
|
||||||
|
'Diagnose-RdpLoginMonitor.ps1',
|
||||||
'login_monitor.settings.example.ps1'
|
'login_monitor.settings.example.ps1'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
2.0.34-SAC
|
2.1.15-SAC
|
||||||
|
|||||||
Reference in New Issue
Block a user