Compare commits
151 Commits
| 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 | |||
| 91cde1668c | |||
| 09f1b576bf | |||
| b6d293d21b | |||
| 6ee5fdcbf2 | |||
| 0da1d0f2ac | |||
| 5e9939724c | |||
| 31ebd5d653 | |||
| 5c23ac5f36 | |||
| 6fa940dd43 | |||
| 463a3ef08c | |||
| 399597c8a1 | |||
| 365a7113cf | |||
| 01d7525c4a | |||
| 1d30ca2284 | |||
| 80b2337d61 | |||
| fe2e6d624f | |||
| 82c63fb9a8 | |||
| bd83dc84dc | |||
| afded8a495 | |||
| 5fc5f2a9ad | |||
| e3149785dc | |||
| 6384a45395 | |||
| 0acd591c83 | |||
| fa5248d501 | |||
| f08a8a8561 | |||
| 550cea9759 | |||
| e00513f050 | |||
| 22ac7a32c7 | |||
| 92773948a7 | |||
| 7b83a19db0 | |||
| 33367b05a8 | |||
| 94d12e0b5f | |||
| 4b8ff95344 | |||
| 47f95ba186 | |||
| 335768915b | |||
| 5e5bc86e1b | |||
| 42d6af83f2 | |||
| 52e9e62ede | |||
| fae185421a | |||
| afc7bf9dca | |||
| ac095fe1a0 | |||
| 81323e8596 | |||
| 1eedfc736f | |||
| d54d04623f | |||
| 084494cfa8 | |||
| 1a1467f910 | |||
| 543369832a | |||
| f970e284ac | |||
| 6a9b2b70dc | |||
| 2118d4a4a0 | |||
| 78f241df32 | |||
| e1ae9834a9 | |||
| a74c8df877 | |||
| 2395f536cd | |||
| b56ad9c7e4 | |||
| e738d97c9c | |||
| 1fe8bd9f7d | |||
| bf04c8865c | |||
| b010ad01eb | |||
| 1ff8dd1a95 | |||
| 724baa6c0a | |||
| 2bbf1face6 | |||
| d23e99a885 | |||
| 70f06790f6 | |||
| c249451eba | |||
| 1f27272883 | |||
| 49359c07a6 | |||
| 611d9c68ca | |||
| 737da07d6a | |||
| b9d385f51b | |||
| e7f19e5c92 | |||
| e4f4d1bb1f | |||
| 74d1e4db75 | |||
| 5284734092 | |||
| 1c210a40d8 | |||
| 83315cc3b8 | |||
| 04f69aac40 | |||
| 6c86e172b9 | |||
| 71cb1fa9b6 | |||
| ef237f345b | |||
| 1092f6340d | |||
| 9030fb3c30 | |||
| 3ed20b130f | |||
| 1b4ca5fa01 | |||
| a1a3bca0c3 | |||
| 004a8c0026 | |||
| 904ab13880 | |||
| 22a32bc1cb | |||
| bd8076a1d0 | |||
| edbe00ffb3 | |||
| 577b8bc220 | |||
| 971461accf | |||
| 744f254c09 | |||
| 1584be89e2 | |||
| cdf563cbe9 | |||
| 872df98a2c | |||
| 7c13c96622 | |||
| 0c916b50c6 | |||
| 9ab6cc64a1 | |||
| 40c557f041 | |||
| a324841b7d | |||
| a29ef8e0f7 | |||
| 16d555037f | |||
| ab98eab49c | |||
| eccffe927a | |||
| 83f5c7de63 | |||
| 67c33fd4ff | |||
| 6d7b4874ce | |||
| f4375c2349 | |||
| 52b1723137 | |||
| b7786e8690 | |||
| 938df9bfe0 |
@@ -0,0 +1,8 @@
|
||||
.cursor/
|
||||
tools/*.log
|
||||
*.log
|
||||
*.bak
|
||||
Logs/
|
||||
sac-spool/
|
||||
login_monitor.settings.ps1
|
||||
exchange_monitor.settings.ps1
|
||||
@@ -1,195 +1,11 @@
|
||||
# Развёртывание RDP Login Monitor в домене
|
||||
# Развёртывание
|
||||
|
||||
Монитор ставится в **`C:\ProgramData\RDP-login-monitor\`**, задачи планировщика создаёт сам **`Login_Monitor.ps1`** (параметр `-InstallTasks`). Доставку по сети выполняет **`Deploy-LoginMonitor.ps1`**.
|
||||
Документация перенесена в каталог **[Docs/](Docs/README.md)**:
|
||||
|
||||
## Файлы на файловой шаре
|
||||
| Руководство | Файл |
|
||||
|-------------|------|
|
||||
| RDP Login Monitor (NETLOGON, GPO) | [Docs/deploy-rdp-login-monitor.md](Docs/deploy-rdp-login-monitor.md) |
|
||||
| Exchange Mail Security | [Docs/exchange-mail-security.md](Docs/exchange-mail-security.md) |
|
||||
| Публикация на шару | [Docs/deploy-netlogon-publish.md](Docs/deploy-netlogon-publish.md) |
|
||||
|
||||
Создайте каталог, доступный **конечным компьютерам** на чтение (часто учётная запись компьютера домена), например:
|
||||
|
||||
`\\dc.contoso.local\NETLOGON\RDP-login-monitor\`
|
||||
|
||||
Внутри должны лежать **три файла** (имена фиксированы):
|
||||
|
||||
| Файл | Назначение |
|
||||
|------|------------|
|
||||
| `Login_Monitor.ps1` | Основной скрипт (токен/chat id plain или DPAPI в параметрах). |
|
||||
| `version.txt` | **Одна строка** — номер версии пакета на шаре (см. раздел «Версии» ниже). |
|
||||
| `Deploy-LoginMonitor.ps1` | Установщик: сравнивает версию, копирует монитор, вызывает `-InstallTasks`, при необходимости запускает процесс монитора. |
|
||||
|
||||
При выпуске новой сборки обновляйте на шаре **`Login_Monitor.ps1`** и при необходимости **`version.txt`** (логика описана в разделе «Версии»).
|
||||
|
||||
## Как это работает
|
||||
|
||||
1. **`Deploy-LoginMonitor.ps1`** определяет корень дистрибутива:
|
||||
- параметр **`-SourceShareRoot`** `\\server\share\RDP-login-monitor`, **или**
|
||||
- если скрипт запущен по UNC, берётся **родительская папка** этого файла (удобно вызывать шару без параметров).
|
||||
|
||||
2. Читается **`version.txt`** на шаре и сравнивается с локальной меткой **`C:\ProgramData\RDP-login-monitor\deployed_version.txt`**. Если метки ещё нет — для сравнения подтягивается **`$ScriptVersion`** из уже установленного **`Login_Monitor.ps1`**.
|
||||
|
||||
3. Если версия на шаре **совпадает** с зафиксированной локально — выход без копирования (быстро, можно при каждой загрузке).
|
||||
|
||||
4. Если версия на шаре **новее** — останавливаются процессы монитора с каноническим путём → копируется **`Login_Monitor.ps1`** → выполняется **`Login_Monitor.ps1 -InstallTasks`** → записывается **`deployed_version.txt`** → запускается монитор (если не указан **`-SkipStartMonitorAfterUpdate`**).
|
||||
|
||||
5. Если версия на шаре **старее** локальной — обновление **не выполняется** (защита от отката), пока не указан **`-AllowDowngrade`**.
|
||||
|
||||
Лог установки: **`C:\ProgramData\RDP-login-monitor\Logs\deploy.log`**.
|
||||
|
||||
## Опционально на клиенте: `ignore.lst`
|
||||
|
||||
На каждом компьютере/сервере можно положить файл **`C:\ProgramData\RDP-login-monitor\ignore.lst`** (в том же каталоге, что и **`Login_Monitor.ps1`**). По строкам этого файла монитор **не отправляет в Telegram** отдельные события **Security `4624`/`4625`** (например, шум от известной рабочей станции, тестового пользователя или фиксированного IP).
|
||||
|
||||
- **`Deploy-LoginMonitor.ps1` с шары `ignore.lst` не доставляет** — при необходимости создавайте файл локально или копируйте своим способом.
|
||||
- Примеры синтаксиса — в репозитории в файле **`ignore.lst.example`**.
|
||||
|
||||
## Задачи планировщика после `-InstallTasks`
|
||||
|
||||
Параметр **`Login_Monitor.ps1 -InstallTasks`** создаёт две задачи:
|
||||
|
||||
| Имя | Назначение |
|
||||
|-----|------------|
|
||||
| **`RDP-Login-Monitor`** | Запуск основного монитора при старте ОС (`Register-ScheduledTask`, триггер «При запуске компьютера»). |
|
||||
| **`RDP-Login-Monitor-Watchdog`** | Тот же файл **`Login_Monitor.ps1`** с аргументом **`-Watchdog`**: короткая проверка «жив ли монитор», при необходимости поднимает процесс. |
|
||||
|
||||
Watchdog регистрируется через **`schtasks.exe /Create /SC MINUTE /MO 5`** (а не через CIM-триггеры PowerShell): на части ОС у объектов триггера нет настраиваемых **`RepetitionInterval`** / длительность «раз и навсегда» режется планировщиком — из‑за этого раньше вторая задача могла не создаваться.
|
||||
|
||||
Перед созданием выполняется **`schtasks /Delete … /F`** (если задачи ещё не было, сообщение об ошибке подавляется — это нормально).
|
||||
|
||||
Сразу после регистрации задач вызывается **`schtasks /Run`** и для **основной задачи**, и для **watchdog**: первый запуск не ждёт перезагрузку и ближайшее 5‑минутное окно.
|
||||
|
||||
Если watchdog срабатывает, а основной монитор «не поднимается», смотрите **`Logs\watchdog.log`** (сообщение о PID и предупреждение, если процесс сразу завершился) и **конец `Logs\login_monitor.log`** — частая причина (исправлено в **1.3.5+**): пустой **`$PSCommandPath`** у процесса, запущенного через **`Start-Process`**; скрипт теперь подставляет путь через **`$PSScriptRoot`**.
|
||||
|
||||
Один экземпляр монитора фиксируется **файлом блокировки** в **`C:\ProgramData\RDP-login-monitor\.login_monitor_single_instance.lock`** (не Global mutex): так и **SYSTEM** (задача планировщика), и **интерактивный администратор** могут корректно запускать скрипт вручную без «Отказано в доступе» к mutex (версия **1.3.6+**).
|
||||
|
||||
Проверка:
|
||||
|
||||
```powershell
|
||||
Get-ScheduledTask -TaskName 'RDP-Login-Monitor','RDP-Login-Monitor-Watchdog' -ErrorAction SilentlyContinue
|
||||
schtasks /Query /TN "RDP-Login-Monitor-Watchdog"
|
||||
```
|
||||
|
||||
Логи: **`...\Logs\login_monitor.log`**, **`...\Logs\watchdog.log`**.
|
||||
|
||||
## Запуск Deploy и политики с файловой шары
|
||||
|
||||
Если при запуске **`Deploy-LoginMonitor.ps1`** или **`Login_Monitor.ps1`** с UNC по **FQDN** (`\\dc.domain.local\...`) PowerShell сообщает про **цифровую подпись** / политику выполнения, чаще всего помогает путь по **короткому имени** контроллера: **`\\DC01\NETLOGON\...`** (клиент относит UNC к интрасети иначе). Параллельно убедитесь, что команда реально с **`powershell.exe -ExecutionPolicy Bypass -File "..."`**.
|
||||
|
||||
## Версии: `version.txt` и `$ScriptVersion`
|
||||
|
||||
| Что | Роль |
|
||||
|-----|------|
|
||||
| **`version.txt` на шаре** | Единственный источник для **`Deploy-LoginMonitor.ps1`**: решение «класть ли новый файл на компьютер». Поднимайте номер **всякий раз**, когда нужно, чтобы доменные машины забрали **новую копию** скрипта с шары — в том числе при мелких правках **без** изменения «видимой» версии в логах. |
|
||||
| **`$ScriptVersion` в `Login_Monitor.ps1`** | Версия для **логов и Telegram** (что видит администратор). Меняйте при **значимых** релизах; для полной ясности можно держать ту же строку, что и в **`version.txt`**. |
|
||||
|
||||
**Типичные сценарии:**
|
||||
|
||||
- Крупный релиз: обновили **`Login_Monitor.ps1`**, подняли **`$ScriptVersion`** (например `1.4.0`) и записали то же в **`version.txt`** на шаре.
|
||||
- Мелкая правка на шаре (опечатка, узкий фикс), не хотите путать отчёты по версии в логах: поднимите только **`version.txt`** (например с `1.3.0` на **`1.3.0.1`** — поддерживаются четырёхкомпонентные номера .NET **Version**). **`$ScriptVersion`** можно не трогать; на клиентах после деплоя в логах по-прежнему будет старая «человеческая» версия, но файл будет актуальным.
|
||||
- Идеально поддерживать синхронность **`version.txt`** и **`$ScriptVersion`**, когда правки крупные и версия в логах должна совпадать с дистрибутивом.
|
||||
|
||||
Итого: **обновляться «по сети» обязан именно `version.txt`**; строка **`$ScriptVersion`** нужна для прозрачности в мониторинге и может совпадать с шарой или отставать по «маркетингу», если вы сознательно крутите только patch в **`version.txt`**.
|
||||
|
||||
## Групповая политика (GPO): проверенная схема
|
||||
|
||||
Ниже шаги, которые были проверены на практике для запуска деплоя при старте компьютера.
|
||||
|
||||
1. Положите три файла на шару (см. выше), например:
|
||||
|
||||
`\\B26\NETLOGON\RDP-login-monitor\`
|
||||
|
||||
2. Создайте/настройте GPO и привяжите её к OU с **компьютерами** (не пользователями).
|
||||
|
||||
3. В GPO откройте:
|
||||
**Конфигурация компьютера** → **Политики** → **Конфигурация Windows** → **Сценарии (запуск/завершение)** → **Автозагрузка**.
|
||||
|
||||
4. На вкладке **«Сценарии PowerShell»** добавьте скрипт:
|
||||
|
||||
`\\B26\NETLOGON\RDP-login-monitor\Deploy-LoginMonitor.ps1`
|
||||
|
||||
Параметры можно оставить пустыми, если рядом на шаре лежат `Login_Monitor.ps1` и `version.txt`.
|
||||
|
||||
5. Security Filtering:
|
||||
- можно убрать `Authenticated Users`,
|
||||
- добавить группу компьютеров (например `B26\RDP-Login`),
|
||||
- убедиться, что у неё есть права **Read** + **Apply group policy**.
|
||||
|
||||
6. Проверьте, что в группе действительно состоит объект компьютера, например `FVG-PC$`.
|
||||
|
||||
7. Проверьте доступ на шару/NTFS для контекста компьютера (обычно через `Domain Computers` или целевую группу), потому что startup-скрипт выполняется от имени **SYSTEM**.
|
||||
|
||||
### Важные нюансы
|
||||
|
||||
- Кнопка **«Показать файлы»** в свойствах Startup показывает папку GPO в `SYSVOL` (`...\Policies\{GUID}\Machine\Scripts\Startup`).
|
||||
- Если вы указали в настройке сценария **UNC путь в NETLOGON**, ваш `.ps1` в этой папке `SYSVOL` не появится — это нормально.
|
||||
- После изменения membership компьютера в security-группе часто требуется **перезагрузка** (не только `gpupdate /force`), чтобы обновился токен компьютера.
|
||||
|
||||
### Как запускать без ожидания перезагрузки
|
||||
|
||||
- Startup-сценарий GPO штатно отрабатывает при загрузке ОС, но сам `Deploy-LoginMonitor.ps1` после `-InstallTasks` теперь инициирует немедленный `schtasks /Run`:
|
||||
- `RDP-Login-Monitor`
|
||||
- `RDP-Login-Monitor-Watchdog`
|
||||
- Поэтому после фактического запуска deploy монитор и watchdog стартуют сразу.
|
||||
|
||||
### Серверы без перезагрузок: периодический Deploy
|
||||
|
||||
Если сервер перезагружается редко, одного Startup-сценария недостаточно для своевременного обновления. В этом случае добавьте отдельную задачу, которая периодически запускает `Deploy-LoginMonitor.ps1` с шары.
|
||||
|
||||
Если серверов немного, можно обойтись без отдельной задачи: при выпуске новой версии выполнять `Deploy-LoginMonitor.ps1` вручную на нужных серверах.
|
||||
|
||||
Готовый скрипт в репозитории:
|
||||
|
||||
- `Install-DeployScheduledTask.ps1`
|
||||
|
||||
Этот вариант удобен для большого парка серверов, когда обновления нужно подтягивать автоматически и без ручного обхода.
|
||||
|
||||
Пример (каждые 60 минут и сразу запустить):
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\Install-DeployScheduledTask.ps1" `
|
||||
-TaskName "RDP-Login-Monitor-Deploy" `
|
||||
-DeployScriptPath "\\B26\NETLOGON\RDP-login-monitor\Deploy-LoginMonitor.ps1" `
|
||||
-RepeatMinutes 60 `
|
||||
-RunNow
|
||||
```
|
||||
|
||||
Проверка:
|
||||
|
||||
```powershell
|
||||
schtasks /Query /TN "RDP-Login-Monitor-Deploy" /V /FO LIST
|
||||
```
|
||||
|
||||
Важно: эта задача отвечает только за доставку обновлений (через `version.txt`). За «живость» процесса по-прежнему отвечает `RDP-Login-Monitor-Watchdog`.
|
||||
|
||||
Пример ручного запуска (для небольшого количества серверов):
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "\\B26\NETLOGON\RDP-login-monitor\Deploy-LoginMonitor.ps1"
|
||||
```
|
||||
|
||||
### Диагностика GPO/Startup
|
||||
|
||||
- События применения политики и startup-сценариев:
|
||||
- `Event Viewer -> Applications and Services Logs -> Microsoft -> Windows -> GroupPolicy -> Operational`
|
||||
- Логи на клиенте:
|
||||
- `C:\ProgramData\RDP-login-monitor\Logs\deploy.log`
|
||||
- `C:\ProgramData\RDP-login-monitor\Logs\login_monitor.log`
|
||||
- `C:\ProgramData\RDP-login-monitor\Logs\watchdog.log`
|
||||
- Быстрая ручная проверка deploy:
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "\\B26\NETLOGON\RDP-login-monitor\Deploy-LoginMonitor.ps1"
|
||||
```
|
||||
|
||||
## Ручная проверка
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "\\...\Deploy-LoginMonitor.ps1"
|
||||
```
|
||||
|
||||
- **`-WhatIf`** — только сообщение в лог, без копирования.
|
||||
- **`-SkipStartMonitorAfterUpdate`** — после обновления не запускать процесс монитора (остаются задачи планировщика и следующая загрузка / watchdog).
|
||||
|
||||
## Безопасность и замечания
|
||||
|
||||
- ACL на шару: чтение только нужным **компьютерам** / группам; файл **`Login_Monitor.ps1`** может содержать токен — ограничивайте доступ.
|
||||
- DPAPI-секреты привязаны к машине: шифровать на каждой цели или использовать plain на закрытой шаре (см. комментарии в **`Login_Monitor.ps1`**).
|
||||
- Deploy при ошибках пишет в **`deploy.log`** и завершается с кодом **0**, чтобы не блокировать загрузку ОС; проблемы смотрите по логу на ПК.
|
||||
Оглавление: **[Docs/README.md](Docs/README.md)**
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Deploy domain monitor scripts (Exchange / AD) from share to ProgramData.
|
||||
.PARAMETER Target
|
||||
Exchange - Exchange-MailSecurity.ps1 + Notify-Common.ps1; Ad - reserved.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidateSet('Exchange', 'Ad')]
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Target,
|
||||
[string]$SourceShareRoot = '',
|
||||
[switch]$WhatIf,
|
||||
[switch]$SkipInstallTasks,
|
||||
[switch]$AllowDowngrade
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$InstallRoot = [System.IO.Path]::GetFullPath("$env:ProgramData\RDP-login-monitor")
|
||||
$VersionStampPath = Join-Path $InstallRoot 'deployed_domain_monitors_version.txt'
|
||||
$DeployLogPath = Join-Path $InstallRoot 'Logs\deploy_domain_monitors.log'
|
||||
$VersionFileName = 'version.txt'
|
||||
$Utf8Bom = New-Object System.Text.UTF8Encoding $true
|
||||
$PsExe = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe"
|
||||
|
||||
$ExchangeFiles = @(
|
||||
'Exchange-MailSecurity.ps1',
|
||||
'Notify-Common.ps1',
|
||||
'Install-DomainMonitors.ps1',
|
||||
'exchange_monitor.settings.example.ps1'
|
||||
)
|
||||
|
||||
function Write-DeployLog {
|
||||
param([string]$Message)
|
||||
$line = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') - [$Target] $Message" + [Environment]::NewLine
|
||||
try {
|
||||
$dir = Split-Path $DeployLogPath -Parent
|
||||
if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
|
||||
[System.IO.File]::AppendAllText($DeployLogPath, $line, $Utf8Bom)
|
||||
} catch { }
|
||||
Write-Host $line.TrimEnd("`r`n")
|
||||
}
|
||||
|
||||
function Write-TextFileUtf8Bom {
|
||||
param([string]$Path, [string]$Text)
|
||||
[System.IO.File]::WriteAllText($Path, $Text, $Utf8Bom)
|
||||
}
|
||||
|
||||
function Copy-ScriptFileWithUtf8Bom {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$SourcePath,
|
||||
[Parameter(Mandatory = $true)][string]$DestPath
|
||||
)
|
||||
$raw = [System.IO.File]::ReadAllBytes($SourcePath)
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
|
||||
$text = $utf8NoBom.GetString($raw)
|
||||
if ($text.Length -gt 0 -and [int][char]$text[0] -eq 0xFEFF) {
|
||||
$text = $text.Substring(1)
|
||||
}
|
||||
[System.IO.File]::WriteAllText($DestPath, $text, $Utf8Bom)
|
||||
}
|
||||
|
||||
function Resolve-SourceShareRoot {
|
||||
if (-not [string]::IsNullOrWhiteSpace($SourceShareRoot)) {
|
||||
return [System.IO.Path]::GetFullPath($SourceShareRoot.TrimEnd('\'))
|
||||
}
|
||||
$here = $PSCommandPath
|
||||
if ([string]::IsNullOrWhiteSpace($here)) { $here = $MyInvocation.MyCommand.Path }
|
||||
if ([string]::IsNullOrWhiteSpace($here)) {
|
||||
throw 'Specify -SourceShareRoot or run this script by full UNC path to the .ps1 file on the share.'
|
||||
}
|
||||
return [System.IO.Path]::GetFullPath((Split-Path -Parent $here))
|
||||
}
|
||||
|
||||
function Read-VersionLineFromFile {
|
||||
param([string]$Path)
|
||||
if (-not (Test-Path -LiteralPath $Path)) { return $null }
|
||||
$raw = (Get-Content -LiteralPath $Path -TotalCount 3 -ErrorAction Stop | Where-Object { $_ -match '\S' }) | Select-Object -First 1
|
||||
if ($null -eq $raw) { return $null }
|
||||
return ([string]$raw).Trim() -replace '^v', ''
|
||||
}
|
||||
|
||||
function Compare-SemVerLike {
|
||||
param([string]$A, [string]$B)
|
||||
try {
|
||||
$va = [version]$A
|
||||
$vb = [version]$B
|
||||
return $va.CompareTo($vb)
|
||||
} catch {
|
||||
return [string]::Compare($A, $B, [StringComparison]::OrdinalIgnoreCase)
|
||||
}
|
||||
}
|
||||
|
||||
$shareRoot = Resolve-SourceShareRoot
|
||||
$filesToCopy = switch ($Target) {
|
||||
'Exchange' { $ExchangeFiles }
|
||||
'Ad' { throw 'Target Ad is not supported yet.' }
|
||||
}
|
||||
|
||||
$sourceVersionFile = Join-Path $shareRoot $VersionFileName
|
||||
if (-not (Test-Path -LiteralPath $sourceVersionFile)) {
|
||||
Write-DeployLog "ERROR: missing on share: $sourceVersionFile"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$shareVer = Read-VersionLineFromFile -Path $sourceVersionFile
|
||||
$localVer = Read-VersionLineFromFile -Path $VersionStampPath
|
||||
Write-DeployLog "Share version=$shareVer local stamp=$localVer"
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($localVer)) {
|
||||
$cmp = Compare-SemVerLike -A $shareVer -B $localVer
|
||||
if ($cmp -eq 0) {
|
||||
Write-DeployLog 'Version match - skip copy.'
|
||||
if (-not $SkipInstallTasks) {
|
||||
Write-DeployLog 'Running InstallTasks check...'
|
||||
if (-not $WhatIf) {
|
||||
& $PsExe -NoProfile -ExecutionPolicy Bypass -File (Join-Path $InstallRoot 'Install-DomainMonitors.ps1') -Target $Target
|
||||
}
|
||||
}
|
||||
exit 0
|
||||
}
|
||||
if ($cmp -lt 0 -and -not $AllowDowngrade) {
|
||||
Write-DeployLog 'Share version older than local - skip (use -AllowDowngrade to force).'
|
||||
exit 0
|
||||
}
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $InstallRoot)) {
|
||||
if ($WhatIf) { Write-DeployLog "WhatIf: create $InstallRoot"; exit 0 }
|
||||
New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null
|
||||
}
|
||||
|
||||
foreach ($name in $filesToCopy) {
|
||||
$src = Join-Path $shareRoot $name
|
||||
$dst = Join-Path $InstallRoot $name
|
||||
if (-not (Test-Path -LiteralPath $src)) {
|
||||
Write-DeployLog "ERROR: missing on share: $name"
|
||||
exit 1
|
||||
}
|
||||
if ($WhatIf) {
|
||||
Write-DeployLog "WhatIf: copy $src -> $dst"
|
||||
continue
|
||||
}
|
||||
if ($name -like '*.ps1') {
|
||||
Copy-ScriptFileWithUtf8Bom -SourcePath $src -DestPath $dst
|
||||
} else {
|
||||
Copy-Item -LiteralPath $src -Destination $dst -Force
|
||||
}
|
||||
Write-DeployLog "Copied: $name"
|
||||
}
|
||||
|
||||
if (-not $WhatIf) {
|
||||
Write-TextFileUtf8Bom -Path $VersionStampPath -Text $shareVer
|
||||
if (-not $SkipInstallTasks) {
|
||||
$installer = Join-Path $InstallRoot 'Install-DomainMonitors.ps1'
|
||||
& $PsExe -NoProfile -ExecutionPolicy Bypass -File $installer -Target $Target
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-DeployLog "Install-DomainMonitors exit code: $LASTEXITCODE"
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-DeployLog 'Deploy-DomainMonitors finished.'
|
||||
exit 0
|
||||
+1434
-18
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,447 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Диагностика RDP Login Monitor после входа по RDP (или при «тишине» в Telegram/SAC).
|
||||
.DESCRIPTION
|
||||
Собирает: процесс монитора, задачи планировщика, настройки (без секретов), хвост login_monitor.log,
|
||||
Security 4624/4625/4778/4634, симуляцию фильтров монитора, SAC spool, сессии RDP.
|
||||
Отчёт: C:\ProgramData\RDP-login-monitor\Logs\diagnose_YYYYMMDD_HHmmss.txt
|
||||
.EXAMPLE
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File Diagnose-RdpLoginMonitor.ps1 -MinutesBack 15 -ExpectedUser jdoe
|
||||
.NOTES
|
||||
Рекомендуется запуск от администратора. Без прав Security-журнал может быть неполным.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$InstallRoot = "$env:ProgramData\RDP-login-monitor",
|
||||
[int]$MinutesBack = 15,
|
||||
[int]$MonitorLogTailLines = 120,
|
||||
[string]$ExpectedUser = '',
|
||||
[string]$OutputPath = ''
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Continue'
|
||||
|
||||
function Write-Section {
|
||||
param([string]$Title)
|
||||
$line = ('=' * 72)
|
||||
"`n$line`n $Title`n$line`n"
|
||||
}
|
||||
|
||||
function Redact-SettingsText {
|
||||
param([string]$Text)
|
||||
if ([string]::IsNullOrWhiteSpace($Text)) { return '(файл пуст или не прочитан)' }
|
||||
$out = $Text
|
||||
$out = [regex]::Replace($out, '(?m)^(\s*\$(?:TelegramBotToken|TelegramChatID|SacApiKey|MailSmtpPassword|TelegramBotTokenProtectedB64|TelegramChatIDProtectedB64|MailSmtpPasswordProtectedB64)\s*=).*', '${1}***REDACTED***')
|
||||
return $out
|
||||
}
|
||||
|
||||
function Get-EventDataMapFromEvent {
|
||||
param($Event)
|
||||
$map = @{}
|
||||
try {
|
||||
$xml = [xml]$Event.ToXml()
|
||||
foreach ($d in $xml.Event.EventData.Data) {
|
||||
$name = [string]$d.Name
|
||||
if ([string]::IsNullOrWhiteSpace($name)) { continue }
|
||||
$map[$name] = [string]$d.'#text'
|
||||
}
|
||||
} catch { }
|
||||
return $map
|
||||
}
|
||||
|
||||
function Get-LoginFieldsFromEvent {
|
||||
param($Event)
|
||||
$map = Get-EventDataMapFromEvent -Event $Event
|
||||
$username = $map['TargetUserName']
|
||||
if ([string]::IsNullOrWhiteSpace($username)) { $username = $map['AccountName'] }
|
||||
if ([string]::IsNullOrWhiteSpace($username)) { $username = $map['UserName'] }
|
||||
if ([string]::IsNullOrWhiteSpace($username)) { $username = '-' }
|
||||
|
||||
$computerName = $map['WorkstationName']
|
||||
if ([string]::IsNullOrWhiteSpace($computerName)) { $computerName = $map['ComputerName'] }
|
||||
if ([string]::IsNullOrWhiteSpace($computerName)) { $computerName = '-' }
|
||||
|
||||
$sourceIP = $map['IpAddress']
|
||||
if ([string]::IsNullOrWhiteSpace($sourceIP)) { $sourceIP = $map['SourceNetworkAddress'] }
|
||||
if ([string]::IsNullOrWhiteSpace($sourceIP)) { $sourceIP = '-' }
|
||||
|
||||
$logonType = 0
|
||||
if ($map.ContainsKey('LogonType') -and $map['LogonType'] -match '(\d+)') {
|
||||
$logonType = [int]$Matches[1]
|
||||
}
|
||||
|
||||
$processName = $map['LogonProcessName']
|
||||
if ([string]::IsNullOrWhiteSpace($processName)) { $processName = $map['AuthenticationPackageName'] }
|
||||
if ([string]::IsNullOrWhiteSpace($processName)) { $processName = '-' }
|
||||
|
||||
[pscustomobject]@{
|
||||
TimeCreated = $Event.TimeCreated
|
||||
EventId = [int]$Event.Id
|
||||
Username = $username.Trim()
|
||||
ComputerName = $computerName.Trim()
|
||||
SourceIP = $sourceIP.Trim()
|
||||
LogonType = $logonType
|
||||
ProcessName = $processName.Trim()
|
||||
}
|
||||
}
|
||||
|
||||
function Test-IsWorkstationOs {
|
||||
try {
|
||||
$pt = (Get-CimInstance Win32_OperatingSystem -ErrorAction Stop).ProductType
|
||||
return ($pt -eq 1)
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Get-MonitorVerdictFor4624 {
|
||||
param(
|
||||
$Fields,
|
||||
[bool]$IsWorkstation
|
||||
)
|
||||
|
||||
$reasons = [System.Collections.Generic.List[string]]::new()
|
||||
|
||||
if ($Fields.EventId -ne 4624) {
|
||||
return [pscustomobject]@{ Verdict = 'N/A'; Reasons = @('не 4624') }
|
||||
}
|
||||
|
||||
if ($IsWorkstation) {
|
||||
$allowed = @(10)
|
||||
$modeLabel = 'workstation LT10'
|
||||
} else {
|
||||
$allowed = @(2, 3, 10)
|
||||
$modeLabel = 'server LT2/3/10'
|
||||
}
|
||||
|
||||
if ($allowed -notcontains $Fields.LogonType) {
|
||||
$reasons.Add("LogonType $($Fields.LogonType) not in $modeLabel")
|
||||
}
|
||||
|
||||
$u = $Fields.Username
|
||||
if ([string]::IsNullOrWhiteSpace($u) -or $u -eq '-') { $reasons.Add('пустой Username') }
|
||||
if ($u -match '(?i)(\\)?DWM-\d+') { $reasons.Add('DWM-*') }
|
||||
if ($u -match '(?i)(\\)?UMFD-\d+') { $reasons.Add('UMFD-*') }
|
||||
if ($u -like '*$') { $reasons.Add('machine account ($)') }
|
||||
if ($u -match '(?i)^(SYSTEM|LOCAL SERVICE|NETWORK SERVICE|ANONYMOUS LOGON)$') { $reasons.Add('служебная учётная запись') }
|
||||
|
||||
if ($Fields.SourceIP -eq '127.0.0.1' -or $Fields.SourceIP -like 'fe80:*' -or $Fields.SourceIP -eq '::1') {
|
||||
$reasons.Add("локальный IP ($($Fields.SourceIP))")
|
||||
}
|
||||
if ($Fields.ComputerName -eq '-' -or $Fields.ComputerName -eq 'N/A') {
|
||||
$reasons.Add('WorkstationName = - (встроенный фильтр монитора)')
|
||||
}
|
||||
|
||||
if ($Fields.ProcessName -like '*NtLmSsp*') { $reasons.Add('Process NtLmSsp') }
|
||||
|
||||
if ($reasons.Count -eq 0) {
|
||||
return [pscustomobject]@{ Verdict = 'WOULD_NOTIFY'; Reasons = @('проходит фильтры монитора (4624)') }
|
||||
}
|
||||
return [pscustomobject]@{ Verdict = 'WOULD_SKIP'; Reasons = $reasons }
|
||||
}
|
||||
|
||||
function Get-ExternalCommandOutput {
|
||||
param(
|
||||
[string]$Label,
|
||||
[scriptblock]$Block
|
||||
)
|
||||
$sb = New-Object System.Text.StringBuilder
|
||||
[void]$sb.AppendLine("--- $Label ---")
|
||||
try {
|
||||
$out = & $Block 2>&1
|
||||
if ($null -eq $out) {
|
||||
[void]$sb.AppendLine('(нет вывода)')
|
||||
} else {
|
||||
foreach ($line in @($out)) {
|
||||
[void]$sb.AppendLine([string]$line)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
[void]$sb.AppendLine("ERROR: $($_.Exception.Message)")
|
||||
}
|
||||
return $sb.ToString()
|
||||
}
|
||||
|
||||
$startedAt = Get-Date
|
||||
$isAdmin = $false
|
||||
try {
|
||||
$id = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
$p = New-Object Security.Principal.WindowsPrincipal($id)
|
||||
$isAdmin = $p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
} catch { }
|
||||
|
||||
if (-not $OutputPath) {
|
||||
$logDir = Join-Path $InstallRoot 'Logs'
|
||||
if (-not (Test-Path -LiteralPath $logDir)) {
|
||||
New-Item -ItemType Directory -Path $logDir -Force | Out-Null
|
||||
}
|
||||
$OutputPath = Join-Path $logDir ("diagnose_{0:yyyyMMdd_HHmmss}.txt" -f $startedAt)
|
||||
}
|
||||
|
||||
$report = New-Object System.Text.StringBuilder
|
||||
[void]$report.AppendLine('RDP Login Monitor — диагностический отчёт')
|
||||
[void]$report.AppendLine("Сформирован: $($startedAt.ToString('yyyy-MM-dd HH:mm:ss'))")
|
||||
[void]$report.AppendLine("Компьютер: $env:COMPUTERNAME")
|
||||
[void]$report.AppendLine("Пользователь сеанса: $([Environment]::UserDomainName)\$([Environment]::UserName)")
|
||||
[void]$report.AppendLine("Elevated (Admin): $isAdmin")
|
||||
[void]$report.AppendLine("InstallRoot: $InstallRoot")
|
||||
[void]$report.AppendLine("MinutesBack: $MinutesBack")
|
||||
if ($ExpectedUser) { [void]$report.AppendLine("ExpectedUser: $ExpectedUser") }
|
||||
[void]$report.AppendLine("ReportPath: $OutputPath")
|
||||
|
||||
[void]$report.Append((Write-Section '1. Процесс Login_Monitor.ps1'))
|
||||
$procs = @(
|
||||
Get-CimInstance Win32_Process -Filter "Name='powershell.exe' OR Name='pwsh.exe'" -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.CommandLine -match 'Login_Monitor\.ps1' }
|
||||
)
|
||||
if ($procs.Count -eq 0) {
|
||||
[void]$report.AppendLine('НЕ НАЙДЕН процесс Login_Monitor.ps1')
|
||||
} else {
|
||||
foreach ($proc in $procs) {
|
||||
[void]$report.AppendLine("PID=$($proc.ProcessId) Start=$($proc.CreationDate)")
|
||||
[void]$report.AppendLine(" $($proc.CommandLine)")
|
||||
}
|
||||
}
|
||||
|
||||
[void]$report.Append((Write-Section '2. Scheduled Tasks'))
|
||||
foreach ($tn in @('RDP-Login-Monitor', 'RDP-Login-Monitor-Watchdog')) {
|
||||
[void]$report.AppendLine("--- $tn ---")
|
||||
$q = schtasks.exe /Query /TN $tn /FO LIST /V 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
[void]$report.AppendLine(' (задача не найдена или ошибка запроса)')
|
||||
} else {
|
||||
foreach ($line in @($q)) { [void]$report.AppendLine(" $line") }
|
||||
}
|
||||
}
|
||||
|
||||
[void]$report.Append((Write-Section '3. Версии / deploy markers'))
|
||||
foreach ($rel in @('version.txt', 'deployed_version.txt', 'deploy_last_update.txt', 'restart.request')) {
|
||||
$fp = Join-Path $InstallRoot $rel
|
||||
[void]$report.AppendLine("--- $rel ---")
|
||||
if (Test-Path -LiteralPath $fp) {
|
||||
Get-Content -LiteralPath $fp -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
[void]$report.AppendLine(" $_")
|
||||
}
|
||||
} else {
|
||||
[void]$report.AppendLine(' (нет файла)')
|
||||
}
|
||||
}
|
||||
|
||||
$lmPath = Join-Path $InstallRoot 'Login_Monitor.ps1'
|
||||
if (Test-Path -LiteralPath $lmPath) {
|
||||
$verLine = Select-String -LiteralPath $lmPath -Pattern '^\$ScriptVersion\s*=' -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($verLine) {
|
||||
[void]$report.AppendLine("Login_Monitor.ps1: $($verLine.Line.Trim())")
|
||||
}
|
||||
}
|
||||
|
||||
[void]$report.Append((Write-Section '4. login_monitor.settings.ps1 (redacted)'))
|
||||
$settingsPath = Join-Path $InstallRoot 'login_monitor.settings.ps1'
|
||||
if (Test-Path -LiteralPath $settingsPath) {
|
||||
$raw = Get-Content -LiteralPath $settingsPath -Raw -ErrorAction SilentlyContinue
|
||||
[void]$report.AppendLine(Redact-SettingsText -Text $raw)
|
||||
} else {
|
||||
[void]$report.AppendLine('Файл settings не найден')
|
||||
}
|
||||
|
||||
[void]$report.AppendLine('')
|
||||
[void]$report.AppendLine('--- ignore.lst ---')
|
||||
$ignorePath = Join-Path $InstallRoot 'ignore.lst'
|
||||
if (Test-Path -LiteralPath $ignorePath) {
|
||||
Get-Content -LiteralPath $ignorePath | ForEach-Object { [void]$report.AppendLine(" $_") }
|
||||
} else {
|
||||
[void]$report.AppendLine(' (нет файла)')
|
||||
}
|
||||
|
||||
[void]$report.Append((Write-Section '5. SAC spool / heartbeat'))
|
||||
$spoolDir = Join-Path $InstallRoot 'sac-spool'
|
||||
if (Test-Path -LiteralPath $spoolDir) {
|
||||
$spoolFiles = @(Get-ChildItem -LiteralPath $spoolDir -Filter '*.json' -File -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending)
|
||||
[void]$report.AppendLine("sac-spool: файлов $($spoolFiles.Count)")
|
||||
$spoolFiles | Select-Object -First 5 | ForEach-Object {
|
||||
[void]$report.AppendLine(" $($_.Name) $($_.LastWriteTime)")
|
||||
}
|
||||
} else {
|
||||
[void]$report.AppendLine('sac-spool: каталог отсутствует')
|
||||
}
|
||||
$hbPath = Join-Path $InstallRoot 'heartbeat.txt'
|
||||
if (Test-Path -LiteralPath $hbPath) {
|
||||
[void]$report.AppendLine("heartbeat.txt: $(Get-Content -LiteralPath $hbPath -First 1)")
|
||||
} else {
|
||||
[void]$report.AppendLine('heartbeat.txt: нет')
|
||||
}
|
||||
|
||||
[void]$report.Append((Write-Section "6. login_monitor.log (last $MonitorLogTailLines lines)"))
|
||||
$monLog = Join-Path $InstallRoot 'Logs\login_monitor.log'
|
||||
if (Test-Path -LiteralPath $monLog) {
|
||||
Get-Content -LiteralPath $monLog -Tail $MonitorLogTailLines -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
[void]$report.AppendLine($_)
|
||||
}
|
||||
} else {
|
||||
[void]$report.AppendLine('login_monitor.log не найден')
|
||||
}
|
||||
|
||||
[void]$report.AppendLine('')
|
||||
[void]$report.AppendLine('--- login_monitor.log: Notify / Skip / dedup / rdp.login (last 50 matches) ---')
|
||||
if (Test-Path -LiteralPath $monLog) {
|
||||
Select-String -LiteralPath $monLog -Pattern 'Notify:|Skip 4624|Notify dedup|SAC: accepted.*rdp\.login|type=rdp\.login' -ErrorAction SilentlyContinue |
|
||||
Select-Object -Last 50 |
|
||||
ForEach-Object { [void]$report.AppendLine($_.Line) }
|
||||
}
|
||||
|
||||
[void]$report.Append((Write-Section '7. RDP / interactive sessions'))
|
||||
[void]$report.AppendLine((Get-ExternalCommandOutput -Label 'quser' -Block { quser.exe 2>&1 }))
|
||||
[void]$report.AppendLine((Get-ExternalCommandOutput -Label 'query session' -Block { query.exe session 2>&1 }))
|
||||
|
||||
[void]$report.Append((Write-Section "8. Security log (last $MinutesBack min)"))
|
||||
if (-not $isAdmin) {
|
||||
[void]$report.AppendLine('WARN: скрипт не от администратора — чтение Security может быть неполным.')
|
||||
}
|
||||
|
||||
$since = (Get-Date).AddMinutes(-1 * [math]::Abs($MinutesBack))
|
||||
$RcmLogName = 'Microsoft-Windows-TerminalServices-RemoteConnectionManager/Operational'
|
||||
$isWs = Test-IsWorkstationOs
|
||||
[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('')
|
||||
|
||||
foreach ($eid in @(4624, 4625, 4778, 4634)) {
|
||||
[void]$report.AppendLine("--- Event ID $eid ---")
|
||||
try {
|
||||
$evs = @(Get-WinEvent -FilterHashtable @{
|
||||
LogName = 'Security'
|
||||
ID = $eid
|
||||
StartTime = $since
|
||||
} -ErrorAction Stop | Sort-Object TimeCreated)
|
||||
} catch {
|
||||
[void]$report.AppendLine(" (нет событий или ошибка: $($_.Exception.Message))")
|
||||
continue
|
||||
}
|
||||
|
||||
if ($evs.Count -eq 0) {
|
||||
[void]$report.AppendLine(' (нет событий в окне)')
|
||||
continue
|
||||
}
|
||||
|
||||
foreach ($ev in $evs) {
|
||||
if ($eid -eq 4624 -or $eid -eq 4625) {
|
||||
$f = Get-LoginFieldsFromEvent -Event $ev
|
||||
$verdict = if ($eid -eq 4624) { Get-MonitorVerdictFor4624 -Fields $f -IsWorkstation $isWs } else { $null }
|
||||
|
||||
$marker = ''
|
||||
if ($ExpectedUser -and $f.Username -like "*$ExpectedUser*") { $marker = ' <<<< EXPECTED USER' }
|
||||
|
||||
[void]$report.AppendLine(
|
||||
(" {0:yyyy-MM-dd HH:mm:ss} ID={1} User={2} LT={3} IP={4} Wks={5} Proc={6}{7}" -f
|
||||
$f.TimeCreated, $f.EventId, $f.Username, $f.LogonType, $f.SourceIP, $f.ComputerName, $f.ProcessName, $marker)
|
||||
)
|
||||
if ($verdict) {
|
||||
[void]$report.AppendLine(" MONITOR: $($verdict.Verdict) — $($verdict.Reasons -join '; ')")
|
||||
}
|
||||
} else {
|
||||
[void]$report.AppendLine(" $($ev.TimeCreated.ToString('yyyy-MM-dd HH:mm:ss')) RecordId=$($ev.RecordId)")
|
||||
$f = Get-LoginFieldsFromEvent -Event $ev
|
||||
if ($f.Username -ne '-') {
|
||||
[void]$report.AppendLine(" User=$($f.Username) LT=$($f.LogonType) IP=$($f.SourceIP)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[void]$report.Append((Write-Section '9. Краткий итог'))
|
||||
$recent4624 = @()
|
||||
try {
|
||||
$recent4624 = @(Get-WinEvent -FilterHashtable @{
|
||||
LogName = 'Security'
|
||||
ID = 4624
|
||||
StartTime = $since
|
||||
} -ErrorAction SilentlyContinue)
|
||||
} catch { }
|
||||
|
||||
$notifyable = @()
|
||||
foreach ($ev in $recent4624) {
|
||||
$f = Get-LoginFieldsFromEvent -Event $ev
|
||||
$v = Get-MonitorVerdictFor4624 -Fields $f -IsWorkstation $isWs
|
||||
if ($v.Verdict -eq 'WOULD_NOTIFY') { $notifyable += $f }
|
||||
}
|
||||
|
||||
[void]$report.AppendLine("Security 4624 в окне: $($recent4624.Count)")
|
||||
[void]$report.AppendLine("Из них базовые фильтры монитора пропустили бы: $($notifyable.Count)")
|
||||
|
||||
try {
|
||||
$reconnectCount = @(Get-WinEvent -FilterHashtable @{
|
||||
LogName = 'Security'; ID = 4778; StartTime = $since
|
||||
} -ErrorAction SilentlyContinue).Count
|
||||
[void]$report.AppendLine("Security 4778 (reconnect): $reconnectCount")
|
||||
} catch {
|
||||
[void]$report.AppendLine('Security 4778 (reconnect): n/a')
|
||||
}
|
||||
|
||||
$sacLoginLines = @()
|
||||
if (Test-Path -LiteralPath $monLog) {
|
||||
$sinceLog = $since.ToString('yyyy-MM-dd HH:mm')
|
||||
$sacLoginLines = @(Select-String -LiteralPath $monLog -Pattern 'SAC: accepted.*type=rdp\.login\.success' -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Line -ge $sinceLog } |
|
||||
Select-Object -ExpandProperty Line)
|
||||
}
|
||||
[void]$report.AppendLine("SAC ingest rdp.login.success в login_monitor.log (после $sinceLog): $($sacLoginLines.Count)")
|
||||
foreach ($ln in $sacLoginLines) { [void]$report.AppendLine(" $ln") }
|
||||
|
||||
if ($notifyable.Count -eq 0 -and $recent4624.Count -gt 0) {
|
||||
[void]$report.AppendLine('')
|
||||
[void]$report.AppendLine('Все 4624 в окне отфильтрованы базовыми правилами — см. MONITOR: WOULD_SKIP выше.')
|
||||
[void]$report.AppendLine('Дополнительно: ignore.lst, IgnoreAdvapiNetworkLogonSourceIps в settings, dedup 90s.')
|
||||
} elseif ($recent4624.Count -eq 0) {
|
||||
[void]$report.AppendLine('')
|
||||
[void]$report.AppendLine('Нет 4624 в окне — возможен reconnect (4778) без нового 4624.')
|
||||
[void]$report.AppendLine('Повторите: Sign out → новый RDP → скрипт с -MinutesBack 5.')
|
||||
}
|
||||
|
||||
[void]$report.AppendLine('')
|
||||
[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.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()
|
||||
[System.IO.File]::WriteAllText($OutputPath, $text, (New-Object System.Text.UTF8Encoding $true))
|
||||
|
||||
Write-Host ''
|
||||
Write-Host "Отчёт сохранён: $OutputPath" -ForegroundColor Green
|
||||
Write-Host 'Пришлите этот файл для анализа.' -ForegroundColor Cyan
|
||||
Write-Host ''
|
||||
Get-Content -LiteralPath $OutputPath -Tail 25
|
||||
@@ -0,0 +1,13 @@
|
||||
# Документация RDP-login-monitor
|
||||
|
||||
Руководства по развёртыванию и эксплуатации скриптов мониторинга.
|
||||
|
||||
| Документ | Описание |
|
||||
|----------|----------|
|
||||
| [deploy-rdp-login-monitor.md](deploy-rdp-login-monitor.md) | **Login_Monitor.ps1** — NETLOGON, GPO, задачи планировщика, версии, 4740 на КД |
|
||||
| [exchange-mail-security.md](exchange-mail-security.md) | **Exchange-MailSecurity.ps1** — очереди, пересылка на внешние адреса, установка |
|
||||
| [deploy-netlogon-publish.md](deploy-netlogon-publish.md) | Публикация пакета на шару (`update-rdp-monitor.ps1`) |
|
||||
|
||||
Общий каталог установки на серверах: **`C:\ProgramData\RDP-login-monitor\`**.
|
||||
|
||||
Краткий обзор возможностей — в корневом **[README.md](../README.md)**.
|
||||
@@ -0,0 +1,81 @@
|
||||
# Публикация пакета на NETLOGON
|
||||
|
||||
Скрипт **`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` |
|
||||
| `$NetlogonDest` | `\\dc.contoso.local\NETLOGON\RDP-login-monitor` *(заглушка — замените на свой DC)* |
|
||||
| `$GitUrl` | `https://github.com/PTah/RDP-login-monitor.git` |
|
||||
| `$GitBranch` | `main` |
|
||||
| `$LogFile` | `C:\soft\Logs\update-rdp-monitor.log` |
|
||||
|
||||
## Копируемые файлы
|
||||
|
||||
- `Login_Monitor.ps1`
|
||||
- `Sac-Client.ps1`
|
||||
- `version.txt`
|
||||
- `Deploy-LoginMonitor.ps1`
|
||||
- `Restart-RdpLoginMonitor.ps1`
|
||||
- `Exchange-MailSecurity.ps1`
|
||||
- `Notify-Common.ps1`
|
||||
- `Install-DomainMonitors.ps1`
|
||||
- `Deploy-DomainMonitors.ps1`
|
||||
- `exchange_monitor.settings.example.ps1`
|
||||
- `login_monitor.settings.example.ps1`
|
||||
|
||||
## Запуск
|
||||
|
||||
**На сервере публикации** (клон репозитория + доступ к NETLOGON):
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\Soft\Git\RDP-login-monitor\update-rdp-monitor.ps1 `
|
||||
-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`** по полной строке.
|
||||
|
||||
Файлы **`*.ps1`** при копировании пересохраняются как **UTF-8 с BOM** (иначе PowerShell 5.1 с NETLOGON может не разобрать кириллицу в скриптах). Скрипты **`Deploy-DomainMonitors.ps1`** и **`Install-DomainMonitors.ps1`** используют **ASCII** в рабочих строках — их можно запускать и без перепубликации.
|
||||
@@ -0,0 +1,237 @@
|
||||
# Развёртывание RDP Login Monitor в домене
|
||||
|
||||
Монитор ставится в **`C:\ProgramData\RDP-login-monitor\`**, задачи планировщика создаёт **`Login_Monitor.ps1 -InstallTasks`**. Доставку по сети выполняет **`Deploy-LoginMonitor.ps1`**.
|
||||
|
||||
- Публикация на NETLOGON: [deploy-netlogon-publish.md](deploy-netlogon-publish.md)
|
||||
- Exchange Mail Security (отдельный пакет): [exchange-mail-security.md](exchange-mail-security.md)
|
||||
|
||||
## Файлы на файловой шаре
|
||||
|
||||
Каталог, доступный **конечным компьютерам** на чтение (учётная запись компьютера домена / SYSTEM), например:
|
||||
|
||||
`\\dc.contoso.local\NETLOGON\RDP-login-monitor\`
|
||||
|
||||
| Файл | Назначение |
|
||||
|------|------------|
|
||||
| `Login_Monitor.ps1` | Основной скрипт мониторинга |
|
||||
| `Sac-Client.ps1` | Клиент SAC (обязателен для SAC; сверка SHA256 при деплое) |
|
||||
| `login_monitor.settings.example.ps1` | Образец настроек (bootstrap локального settings) |
|
||||
| `version.txt` | **Одна строка** — версия пакета на шаре (например `1.2.27-SAC`) |
|
||||
| `Deploy-LoginMonitor.ps1` | Установщик для GPO / scheduled task |
|
||||
|
||||
Полный список файлов на шару (включая Exchange): [deploy-netlogon-publish.md](deploy-netlogon-publish.md).
|
||||
|
||||
**Не копируются Deploy-LoginMonitor:** `ignore.lst`, `login_monitor.settings.ps1` (секреты локальны).
|
||||
|
||||
## Алгоритм Deploy (GPO startup / scheduled task)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[GPO startup или RDP-Login-Monitor-Deploy] --> B[Deploy-LoginMonitor.ps1 с UNC]
|
||||
B --> C{Login_Monitor.ps1 и version.txt на шаре?}
|
||||
C -->|нет| Z[exit 0, запись в deploy.log]
|
||||
C -->|да| D[Сравнение version.txt vs deployed_version.txt]
|
||||
D --> E{Нужна работа?}
|
||||
E -->|версия совпадает, bundle OK, settings OK| F["Актуально — exit 0"]
|
||||
E -->|новая версия / патчи / первый запуск| G[Graceful stop монитора]
|
||||
G --> H[Copy Login_Monitor.ps1 + Sac-Client.ps1]
|
||||
H --> I[Sync settings из example + post-patches]
|
||||
I --> J[Login_Monitor.ps1 -InstallTasks]
|
||||
J --> K[deployed_version.txt + deploy_last_update.txt]
|
||||
K --> L[Start monitor]
|
||||
```
|
||||
|
||||
Корень дистрибутива: **`-SourceShareRoot`** или родитель каталога `Deploy-LoginMonitor.ps1` при запуске по UNC.
|
||||
|
||||
Локальная метка версии: **`C:\ProgramData\RDP-login-monitor\deployed_version.txt`**. Если файла нет — версия берётся из **`$ScriptVersion`** в установленном `Login_Monitor.ps1`.
|
||||
|
||||
### Когда деплой **не** копирует файлы
|
||||
|
||||
Версия на шаре **совпадает** с локальной **и** одновременно:
|
||||
|
||||
- SHA256 **`Login_Monitor.ps1`** и **`Sac-Client.ps1`** совпадает с шарой;
|
||||
- в **`login_monitor.settings.ps1`** уже есть настроенный SAC (или не требуется bootstrap);
|
||||
- применены post-patches: подсказки **`$ServerDisplayName`**, **`$DailyReportEnabled`**, на Exchange — noise settings.
|
||||
|
||||
Иначе деплой **продолжается** (даже при совпадении номера версии): другой hash bundle, донастройка SAC, исправление `DailyReportEnabled = false` без `$`, дописывание Exchange-фильтров и т.д.
|
||||
|
||||
### Upgrade / downgrade
|
||||
|
||||
| Ситуация | Поведение |
|
||||
|----------|-----------|
|
||||
| Версия на шаре **новее** | Полный цикл деплоя |
|
||||
| Версия **совпадает**, но нужны патчи | Полный цикл (см. выше) |
|
||||
| Версия на шаре **старее** | Пропуск, пока не указан **`-AllowDowngrade`** |
|
||||
|
||||
Метки с суффиксом (**`1.2.27-SAC`**) сравниваются **по полной строке**; для порядка версий используется числовой префикс `1.2.27`.
|
||||
|
||||
Лог: **`C:\ProgramData\RDP-login-monitor\Logs\deploy.log`**. Ошибки пишутся в лог и завершаются **`exit 0`** (удобно для GPO).
|
||||
|
||||
## Сервер Exchange и GPO Deploy-LoginMonitor
|
||||
|
||||
**`Deploy-LoginMonitor.ps1` не копирует отдельный Exchange-пакет** — на MailServer те же **`Login_Monitor.ps1`** + **`Sac-Client.ps1`**, что на RDS и рабочих станциях.
|
||||
|
||||
При обнаружении роли Exchange (реестр, каталог установки, службы) Deploy **только дописывает** в **`login_monitor.settings.ps1`**, если строк ещё нет:
|
||||
|
||||
- `${Ignore4624-LT3-EmptyIP-Event} = $true`
|
||||
- `$WinRmIgnoreLocalSource = 1`
|
||||
- `$WinRmIgnoreMachineAccounts = 1`
|
||||
- `$WinRmExchangeStrictMode = 1`
|
||||
|
||||
Подавление шума WinRM (HealthMailbox, loopback), ложных WinRM 91↔4624 (Outlook/LT3) и лишних **4624 LT3**.
|
||||
|
||||
**Мониторинг очередей транспорта и правил пересылки** — другой скрипт, **не** через GPO RDP-монитора:
|
||||
|
||||
```powershell
|
||||
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).
|
||||
|
||||
## Локальные настройки: `login_monitor.settings.ps1`
|
||||
|
||||
Путь: **`C:\ProgramData\RDP-login-monitor\login_monitor.settings.ps1`**.
|
||||
|
||||
| Действие Deploy | Условие |
|
||||
|-----------------|--------|
|
||||
| Копирует example → settings | Файла settings ещё нет |
|
||||
| Дописывает блок SAC из example | Нет `$UseSAC` / `$SacUrl` / `$SacApiKey` |
|
||||
| **Не перезаписывает** целиком | SAC уже настроен (`UseSAC` не `off`, ключ и URL заданы) |
|
||||
| Подсказка `# $ServerDisplayName = '<COMPUTERNAME>'` | Строки `$ServerDisplayName` нет |
|
||||
| Подсказка / починка `$DailyReportEnabled` | Нет переменной или `= false` без `$` → `$false` |
|
||||
| Exchange noise (см. выше) | Роль Exchange и нет соответствующих строк |
|
||||
|
||||
Секреты (Telegram, **`sac_*`**, SMTP, 4740) правятся **вручную** на машине.
|
||||
|
||||
```powershell
|
||||
$root = 'C:\ProgramData\RDP-login-monitor'
|
||||
Copy-Item '\\dc.contoso.local\NETLOGON\RDP-login-monitor\login_monitor.settings.example.ps1' `
|
||||
(Join-Path $root 'login_monitor.settings.ps1')
|
||||
notepad (Join-Path $root 'login_monitor.settings.ps1')
|
||||
```
|
||||
|
||||
DPAPI: **`Encrypt-DpapiForRdpMonitor.ps1`**.
|
||||
|
||||
### Security Alert Center
|
||||
|
||||
```powershell
|
||||
$UseSAC = 'dual' # off | exclusive | dual | fallback
|
||||
$SacUrl = 'https://sac.example.com'
|
||||
$SacApiKey = 'sac_...'
|
||||
```
|
||||
|
||||
Проверка: **`Login_Monitor.ps1 -CheckSac`**. После правок settings — **`Restart-RdpLoginMonitor.ps1`** или **`-RequestRestart`**.
|
||||
|
||||
## Задачи, heartbeat, ignore.lst
|
||||
|
||||
| Задача | Назначение |
|
||||
|--------|------------|
|
||||
| **`RDP-Login-Monitor`** | Запуск при старте ОС |
|
||||
| **`RDP-Login-Monitor-Watchdog`** | Контроль процесса каждые 5 мин |
|
||||
|
||||
Логи: **`login_monitor.log`**, **`watchdog.log`**, **`Logs\last_heartbeat.txt`**.
|
||||
|
||||
**`ignore.lst`** — локально, Deploy не копирует. Синтаксис: [README.md](../README.md) (раздел 7).
|
||||
|
||||
**4740 на КД:** только если имя узла совпадает с **`$LockoutMonitorDomainController`** в settings на этом КД.
|
||||
|
||||
## GPO и периодический deploy
|
||||
|
||||
1. Файлы на `\\dc.contoso.local\NETLOGON\RDP-login-monitor\` (`update-rdp-monitor.ps1` на DC публикации).
|
||||
2. GPO на OU **компьютеров** → **Сценарии PowerShell** автозагрузки → `Deploy-LoginMonitor.ps1`.
|
||||
3. Security Filtering: группа компьютеров; на шару — Read для **SYSTEM** / Domain Computers.
|
||||
4. После смены membership — **перезагрузка** (не только `gpupdate`).
|
||||
|
||||
Deploy после `-InstallTasks` запускает монитор; задача **`RDP-Login-Monitor`** поднимет его при следующей загрузке.
|
||||
|
||||
**Серверы без частых перезагрузок:**
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\Install-DeployScheduledTask.ps1" `
|
||||
-TaskName "RDP-Login-Monitor-Deploy" `
|
||||
-DeployScriptPath "\\dc.contoso.local\NETLOGON\RDP-login-monitor\Deploy-LoginMonitor.ps1" `
|
||||
-RepeatMinutes 60 `
|
||||
-RunNow
|
||||
```
|
||||
|
||||
**Ручной deploy** (от администратора):
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "\\dc.contoso.local\NETLOGON\RDP-login-monitor\Deploy-LoginMonitor.ps1"
|
||||
```
|
||||
|
||||
Параметры: **`-WhatIf`**, **`-SkipStartMonitorAfterUpdate`**, **`-AllowDowngrade`**.
|
||||
|
||||
## Обновление: чеклист
|
||||
|
||||
### A. Публикация на шару (один раз на релиз)
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\soft\update-rdp-monitor.ps1
|
||||
```
|
||||
|
||||
Проверьте на шаре **`Sac-Client.ps1`** и **`version.txt`** (например `1.2.27-SAC`).
|
||||
|
||||
### B. На целевых машинах
|
||||
|
||||
GPO или scheduled task — дождаться startup / `-RunNow`. Ручная проверка:
|
||||
|
||||
```powershell
|
||||
Get-Content 'C:\ProgramData\RDP-login-monitor\Logs\deploy.log' -Tail 20
|
||||
Select-String -Path 'C:\ProgramData\RDP-login-monitor\Login_Monitor.ps1' -Pattern 'ScriptVersion'
|
||||
Test-Path 'C:\ProgramData\RDP-login-monitor\Sac-Client.ps1'
|
||||
Get-ScheduledTask -TaskName 'RDP-Login-Monitor','RDP-Login-Monitor-Watchdog' -ErrorAction SilentlyContinue
|
||||
```
|
||||
|
||||
Повторный запуск Deploy без смены версии должен дать **`Актуально, копирование не требуется`**.
|
||||
|
||||
### C. Graceful restart
|
||||
|
||||
После правки settings (без смены `Login_Monitor.ps1`):
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass `
|
||||
-File "C:\ProgramData\RDP-login-monitor\Login_Monitor.ps1" -RequestRestart
|
||||
```
|
||||
|
||||
После Deploy нового **`Login_Monitor.ps1`** — recycle:
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass `
|
||||
-File "C:\ProgramData\RDP-login-monitor\Login_Monitor.ps1" -RequestRestart -Recycle
|
||||
```
|
||||
|
||||
**`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.
|
||||
|
||||
## Версии
|
||||
|
||||
| Файл | Роль |
|
||||
|------|------|
|
||||
| **`version.txt` на шаре** | Триггер обновления для Deploy |
|
||||
| **`deployed_version.txt` локально** | Метка после успешного деплоя |
|
||||
| **`$ScriptVersion` в Login_Monitor.ps1** | Версия в логах и Telegram |
|
||||
|
||||
Поднимайте **`version.txt`** при каждой выкладке на NETLOGON; строка в **`version.txt`** и **`$ScriptVersion`** должны совпадать (включая суффикс `-SAC`).
|
||||
|
||||
## Безопасность и UNC
|
||||
|
||||
- Ограничьте ACL на шару (в example могут быть доменные секреты).
|
||||
- DPAPI для токенов в **`login_monitor.settings.ps1`**.
|
||||
- При ошибках подписи с FQDN-шары: короткое имя DC и **`-ExecutionPolicy Bypass`**.
|
||||
|
||||
Диагностика: Group Policy Operational log, **`Logs\deploy.log`**, **`deploy_installtasks_*.log`**.
|
||||
@@ -0,0 +1,268 @@
|
||||
# Exchange Mail Security — руководство
|
||||
|
||||
Скрипт **`Exchange-MailSecurity.ps1`** предназначен **только для сервера Microsoft Exchange** с Exchange Management Shell (EMS). Не устанавливается на все компьютеры домена через GPO RDP-монитора.
|
||||
|
||||
На Exchange-сервере GPO **`Deploy-LoginMonitor.ps1`** по-прежнему ставит **`Login_Monitor.ps1`** (RDP/WinRM/4624) и при необходимости дописывает noise settings в **`login_monitor.settings.ps1`**. Пакет **`Exchange-MailSecurity.ps1`** доставляется отдельно — см. раздел «Деплой на Exchange» ниже и [deploy-rdp-login-monitor.md](deploy-rdp-login-monitor.md).
|
||||
|
||||
## Назначение
|
||||
|
||||
| Функция | Режим | Расписание (задача планировщика) |
|
||||
|---------|--------|----------------------------------|
|
||||
| **Очереди транспорта** | `-Mode Queues` | `RDP-Exchange-TransportQueues` — каждые **10** мин |
|
||||
| **Пересылка на внешние адреса** (BEC) | `-Mode Inbox` | `RDP-Exchange-InboxRules` — ежедневно **02:00** |
|
||||
| **Контроль heartbeat** | `-Watchdog` | `RDP-Exchange-MailSecurity-Watchdog` — каждые **15** мин |
|
||||
|
||||
Каталог установки (общий с RDP-монитором): **`C:\ProgramData\RDP-login-monitor\`**.
|
||||
|
||||
## Что считается «несанкционированной пересылкой»
|
||||
|
||||
Скрипт ищет доставку почты **на домены вне организации** (не из списка принятых доменов Exchange).
|
||||
|
||||
### 1. Правила Inbox (`Get-InboxRule`)
|
||||
|
||||
Для **включённых** правил проверяются:
|
||||
|
||||
- `ForwardTo`
|
||||
- `RedirectTo`
|
||||
- `ForwardAsAttachmentTo`
|
||||
|
||||
Если целевой SMTP-адрес указывает на домен **не** из `Get-AcceptedDomain` (типы **Authoritative** и **InternalRelay**) и не в **whitelist** — фиксируется находка.
|
||||
|
||||
**Повышенная важность (Критическая):** включённое правило с `DeleteMessage` или `MarkAsRead`.
|
||||
|
||||
**Отключённые правила** (`$ScanDisabledInboxRulesWithExternalForward = $true`): внешняя пересылка фиксируется с важностью **«Средняя (правило отключено)»**.
|
||||
|
||||
### 2. Пересылка на уровне ящика (`Get-Mailbox`)
|
||||
|
||||
Проверяются ящики с заполненными:
|
||||
|
||||
- `ForwardingSmtpAddress`
|
||||
- `ForwardingAddress`
|
||||
|
||||
Это отдельный от Inbox Rule механизм («пересылать всю почту на внешний ящик») — частый вектор BEC.
|
||||
|
||||
### 3. Правила транспорта (`Get-TransportRule`)
|
||||
|
||||
Для **включённых** transport rules проверяются действия с адресами:
|
||||
|
||||
- `RedirectMessageTo`
|
||||
- `BlindCopyTo`
|
||||
- `CopyTo`
|
||||
|
||||
При внешнем SMTP — находка с типом **TransportRule**.
|
||||
|
||||
### Что не отслеживается
|
||||
|
||||
- Делегирование, Send As, скрытые EWS-правила без Inbox rule
|
||||
- Пересылка только между **внутренними** accepted domains
|
||||
- Чтение почты злоумышленником **без** настройки forward
|
||||
|
||||
## Очереди транспорта
|
||||
|
||||
`Get-Queue`: если **`MessageCount`** на очереди больше **`$QueueMessageCountThreshold`** (по умолчанию **150**), отправляется оповещение.
|
||||
|
||||
Повтор алерта по той же очереди — не чаще **`$QueueAlertCooldownSeconds`** (по умолчанию **900** с), состояние в `Logs\exchange_queue_alert_state.json`.
|
||||
|
||||
## Оповещения и baseline
|
||||
|
||||
- Каналы: **Telegram** и/или **Email** (модуль **`Notify-Common.ps1`**).
|
||||
- Пересылка: **`$AlertOnlyOnNewForwardingFindings = $true`** — алерт при **новой** находке (`Logs\exchange_forwarding_baseline.json`).
|
||||
- **Первый скан:** **`$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`** — не слать «всё чисто» при нуле находок.
|
||||
|
||||
## Режим VIP (пилот)
|
||||
|
||||
В **`exchange_monitor.settings.ps1`**:
|
||||
|
||||
```powershell
|
||||
$VipMailboxesOnly = $true
|
||||
$VipMailboxes = @('director@domain.ru', 'cfo@domain.ru')
|
||||
$VipMailboxPatterns = @('*@domain.ru') # опционально
|
||||
```
|
||||
|
||||
- **Список** — точные SMTP.
|
||||
- **Шаблоны** — дополнительно обход `Get-Mailbox` и отбор по `-like`.
|
||||
- Mailbox forwarding и Inbox rules — только в рамках VIP.
|
||||
- Для полного домена: **`$VipMailboxesOnly = $false`**.
|
||||
|
||||
## Файлы на шаре NETLOGON
|
||||
|
||||
| Файл | Назначение |
|
||||
|------|------------|
|
||||
| `Exchange-MailSecurity.ps1` | Основной скрипт |
|
||||
| `Notify-Common.ps1` | Отправка Telegram/Email |
|
||||
| `Install-DomainMonitors.ps1` | `-Target Exchange` → регистрация задач |
|
||||
| `Deploy-DomainMonitors.ps1` | Копирование с шары |
|
||||
| `exchange_monitor.settings.example.ps1` | Образец настроек (на сервер — вручную) |
|
||||
| `version.txt` | Версия пакета (общая с RDP-монитором) |
|
||||
|
||||
## Установка (пошагово)
|
||||
|
||||
### 1. Публикация на шару
|
||||
|
||||
На сервере публикации: `update-rdp-monitor.ps1` (см. [deploy-netlogon-publish.md](deploy-netlogon-publish.md)).
|
||||
|
||||
### 2. Деплой на Exchange (от администратора)
|
||||
|
||||
```powershell
|
||||
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`**, который регистрирует задачи планировщика.
|
||||
|
||||
Метка версии: `deployed_domain_monitors_version.txt`. Лог: `Logs\deploy_domain_monitors.log`.
|
||||
|
||||
### 3. Локальные настройки (один раз)
|
||||
|
||||
```powershell
|
||||
# После Deploy v1.6.4+ образец лежит в ProgramData; иначе — с NETLOGON:
|
||||
$ex = 'C:\ProgramData\RDP-login-monitor'
|
||||
$src = Join-Path $ex 'exchange_monitor.settings.example.ps1'
|
||||
if (-not (Test-Path -LiteralPath $src)) {
|
||||
$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')
|
||||
notepad (Join-Path $ex 'exchange_monitor.settings.ps1')
|
||||
```
|
||||
|
||||
Задайте:
|
||||
|
||||
- `$TelegramBotToken` / `$TelegramChatID` (или DPAPI `...ProtectedB64`)
|
||||
- при необходимости SMTP
|
||||
- `$ExternalDomainWhitelist` — доверенные внешние домены
|
||||
- для пилота: `$VipMailboxesOnly = $true` и `$VipMailboxes = @('user@domain.ru')`
|
||||
|
||||
Файл **`exchange_monitor.settings.ps1` не перезаписывается** при деплое.
|
||||
|
||||
### 4. Права
|
||||
|
||||
Учётная запись **SYSTEM** (задачи планировщика) должна иметь возможность:
|
||||
|
||||
- загрузить EMS на сервере;
|
||||
- выполнять `Get-Mailbox`, `Get-InboxRule`, `Get-Queue`, `Get-TransportRule`, `Get-AcceptedDomain`.
|
||||
|
||||
На практике на Exchange-сервере под SYSTEM это обычно работает при локальном snap-in; при ошибках RBAC — запуск задачи от служебной УЗ с правами просмотра получателей.
|
||||
|
||||
### 5. Удалённый EMS
|
||||
|
||||
Если скрипт запускается **не** на Exchange, укажите в settings:
|
||||
|
||||
```powershell
|
||||
$ExchangeServerFqdn = 'mail.contoso.local'
|
||||
```
|
||||
|
||||
(подключение через `New-PSSession -ConfigurationName Microsoft.Exchange`).
|
||||
|
||||
## Ручной запуск и проверка
|
||||
|
||||
```powershell
|
||||
# Очереди (быстро)
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\RDP-login-monitor\Exchange-MailSecurity.ps1" -Mode Queues
|
||||
|
||||
# Полный скан пересылки (может занять часы на большом количестве ящиков)
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\RDP-login-monitor\Exchange-MailSecurity.ps1" -Mode Inbox
|
||||
|
||||
# Watchdog
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\RDP-login-monitor\Exchange-MailSecurity.ps1" -Watchdog
|
||||
|
||||
# Переустановка задач
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\RDP-login-monitor\Exchange-MailSecurity.ps1" -InstallTasks
|
||||
```
|
||||
|
||||
Проверка задач:
|
||||
|
||||
```powershell
|
||||
schtasks /Query /TN "RDP-Exchange-TransportQueues" /V /FO LIST
|
||||
schtasks /Query /TN "RDP-Exchange-InboxRules" /V /FO LIST
|
||||
schtasks /Query /TN "RDP-Exchange-MailSecurity-Watchdog" /V /FO LIST
|
||||
```
|
||||
|
||||
## Логи и служебные файлы
|
||||
|
||||
| Путь | Назначение |
|
||||
|------|------------|
|
||||
| `Logs\exchange_mail_security.log` | Основной лог |
|
||||
| `Logs\exchange_watchdog.log` | Watchdog |
|
||||
| `Logs\last_exchange_queues_ok.txt` | Успешный скан очередей |
|
||||
| `Logs\last_exchange_inbox_scan_ok.txt` | Успешный скан пересылки |
|
||||
| `Logs\exchange_forwarding_baseline.json` | Baseline находок (id правил/пересылок) |
|
||||
| `Logs\exchange_queue_alert_state.json` | Cooldown алертов по очередям |
|
||||
|
||||
### Watchdog
|
||||
|
||||
- Нет обновления **очередей** > **20** мин (`$QueuesHeartbeatStaleSeconds`) → алерт.
|
||||
- Нет обновления **inbox scan** > **26** ч (`$InboxHeartbeatStaleSeconds`) → алерт.
|
||||
|
||||
## Основные параметры в `Exchange-MailSecurity.ps1`
|
||||
|
||||
| Параметр | По умолчанию | Смысл |
|
||||
|----------|--------------|--------|
|
||||
| `$QueueMessageCountThreshold` | 150 | Порог сообщений в очереди |
|
||||
| `$QueueAlertCooldownSeconds` | 900 | Пауза между алертами по одной очереди |
|
||||
| `$ScanInboxRules` | `$true` | Скан Inbox rules |
|
||||
| `$ScanMailboxForwarding` | `$true` | Скан forwarding на ящике |
|
||||
| `$ScanTransportRules` | `$true` | Скан transport rules |
|
||||
| `$AlertOnlyOnNewForwardingFindings` | `$true` | Только новые находки |
|
||||
| `$VipMailboxesOnly` | `$false` | Только VIP-ящики |
|
||||
| `$VipMailboxPatterns` | `@()` | Wildcard для VIP |
|
||||
| `$SuppressAlertsOnFirstBaselineRun` | `$true` | Первый скан без N алертов |
|
||||
| `$ScanDisabledInboxRulesWithExternalForward` | `$true` | Отключённые правила с внешним forward |
|
||||
| `$SendInboxScanSummary` | `$true` | Сводка после скана |
|
||||
| `$InboxScanBatchSize` | 50 | Пауза каждые N ящиков |
|
||||
| `$InboxScanBatchDelaySeconds` | 3 | Задержка между батчами |
|
||||
| `$ExcludeMailboxPatterns` | HealthMailbox*, … | Исключения |
|
||||
| `$SkipInboxScanMailboxes` | `k.selezneva@example.com` | Не вызывать `Get-InboxRule` (битый rule store) |
|
||||
|
||||
Переопределение — в **`exchange_monitor.settings.ps1`**.
|
||||
|
||||
После починки ящика удалите его из `$SkipInboxScanMailboxes`.
|
||||
|
||||
## Формат оповещения (пересылка)
|
||||
|
||||
Пример:
|
||||
|
||||
```text
|
||||
📧 Exchange: пересылка на внешний адрес
|
||||
Тип: InboxRule | MailboxForwarding | TransportRule
|
||||
Ящик: user@example.com
|
||||
Правило: …
|
||||
Куда: attacker@gmail.com (внешний)
|
||||
Важность: Критическая | Высокая
|
||||
```
|
||||
|
||||
## Обновление версии
|
||||
|
||||
1. Поднять **`version.txt`** в репозитории и на шаре.
|
||||
2. `update-rdp-monitor.ps1` или ручное копирование.
|
||||
3. На Exchange:
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "\\dc.contoso.local\NETLOGON\RDP-login-monitor\Deploy-DomainMonitors.ps1" -Target Exchange
|
||||
```
|
||||
|
||||
## Устранение неполадок
|
||||
|
||||
| Симптом | Действие |
|
||||
|---------|----------|
|
||||
| «Не удалось загрузить Exchange Management Shell» | Запуск на mailbox/Exchange server; проверить snap-in / `$ExchangeServerFqdn` |
|
||||
| Нет алертов | Проверить `exchange_monitor.settings.ps1`, Telegram/SMTP, лог `exchange_mail_security.log` |
|
||||
| Слишком много алертов в первый день | По умолчанию подавлены (`$SuppressAlertsOnFirstBaselineRun`); иначе whitelist + baseline |
|
||||
| Inbox scan долгий | `$VipMailboxesOnly = $true`; увеличить `$InboxScanBatchDelaySeconds` |
|
||||
| Ошибки Get-InboxRule на ящике | В логе будет «ошибка mailbox» — штатно, скан продолжается |
|
||||
|
||||
## Связанные документы
|
||||
|
||||
- [deploy-rdp-login-monitor.md](deploy-rdp-login-monitor.md) — RDP-монитор на ПК/серверах
|
||||
- [deploy-netlogon-publish.md](deploy-netlogon-publish.md) — выкладка на шару
|
||||
- [README.md](README.md) — оглавление Docs
|
||||
@@ -1,5 +1,6 @@
|
||||
# Запуск: от администратора на ТОМ ЖЕ компьютере, где будет Login_Monitor.ps1.
|
||||
# Результат (Base64) вставьте в $TelegramBotTokenProtectedB64 / $TelegramChatIDProtectedB64.
|
||||
# Запуск: от администратора на ТОМ ЖЕ компьютере, где будет Login_Monitor.ps1.
|
||||
# Результат (Base64) вставьте в login_monitor.settings.ps1 или exchange_monitor.settings.ps1:
|
||||
# $TelegramBotTokenProtectedB64 / $TelegramChatIDProtectedB64 / $MailSmtpPasswordProtectedB64.
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$PlainText
|
||||
)
|
||||
|
||||
@@ -0,0 +1,935 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Мониторинг Exchange: очереди транспорта, пересылка на внешние адреса (Inbox rules + mailbox forwarding + transport rules).
|
||||
.DESCRIPTION
|
||||
Запуск на сервере с Exchange Management Shell (локальный snap-in или remote).
|
||||
Режимы: -Mode Queues (каждые 10 мин по задаче), -Mode Inbox (ежедневный скан), -Watchdog (проверка heartbeat).
|
||||
Установка задач: -InstallTasks
|
||||
.NOTES
|
||||
Каталог: C:\ProgramData\RDP-login-monitor\
|
||||
Опционально: exchange_monitor.settings.ps1 в том же каталоге (секреты, whitelist).
|
||||
#>
|
||||
|
||||
[CmdletBinding(SupportsShouldProcess = $true)]
|
||||
param(
|
||||
[ValidateSet('Queues', 'Inbox', 'Watchdog')]
|
||||
[string]$Mode = 'Queues',
|
||||
[switch]$InstallTasks,
|
||||
[switch]$Watchdog,
|
||||
[switch]$WhatIf
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# ============================================
|
||||
# КОНФИГУРАЦИЯ
|
||||
# ============================================
|
||||
|
||||
$ScriptVersion = '1.6.8'
|
||||
$script:InstallRoot = [System.IO.Path]::GetFullPath("$env:ProgramData\RDP-login-monitor")
|
||||
$script:CanonicalScriptName = 'Exchange-MailSecurity.ps1'
|
||||
$LogFile = Join-Path $script:InstallRoot 'Logs\exchange_mail_security.log'
|
||||
$WatchdogLogFile = Join-Path $script:InstallRoot 'Logs\exchange_watchdog.log'
|
||||
$QueuesHeartbeatFile = Join-Path $script:InstallRoot 'Logs\last_exchange_queues_ok.txt'
|
||||
$InboxHeartbeatFile = Join-Path $script:InstallRoot 'Logs\last_exchange_inbox_scan_ok.txt'
|
||||
$ForwardingBaselineFile = Join-Path $script:InstallRoot 'Logs\exchange_forwarding_baseline.json'
|
||||
$QueueAlertStateFile = Join-Path $script:InstallRoot 'Logs\exchange_queue_alert_state.json'
|
||||
|
||||
# --- Уведомления (как Login_Monitor.ps1; можно вынести в exchange_monitor.settings.ps1) ---
|
||||
$NotifyOrder = 'tg'
|
||||
$TelegramBotToken = ''
|
||||
$TelegramChatID = ''
|
||||
$TelegramBotTokenProtectedB64 = ''
|
||||
$TelegramChatIDProtectedB64 = ''
|
||||
$MailSmtpHost = ''
|
||||
$MailSmtpPort = 587
|
||||
$MailSmtpUser = ''
|
||||
$MailSmtpPassword = ''
|
||||
$MailSmtpPasswordProtectedB64 = ''
|
||||
$MailFrom = ''
|
||||
$MailTo = ''
|
||||
$MailSmtpStartTls = $true
|
||||
$MailSmtpSsl = $false
|
||||
|
||||
# --- Exchange / сканирование ---
|
||||
$ExchangeServerFqdn = '' # пусто = локальный snap-in на этом сервере
|
||||
$QueueMessageCountThreshold = 150
|
||||
$QueueAlertCooldownSeconds = 900
|
||||
$ExternalDomainWhitelist = @() # partner-bank.ru
|
||||
$AlertOnlyOnNewForwardingFindings = $true
|
||||
$ScanMailboxForwarding = $true
|
||||
$ScanInboxRules = $true
|
||||
$ScanTransportRules = $true
|
||||
# VIP: только перечисленные ящики и/или шаблоны (пилот перед полным сканом).
|
||||
$VipMailboxesOnly = $false
|
||||
$VipMailboxes = @() # точные PrimarySmtpAddress: user@domain.com
|
||||
$VipMailboxPatterns = @() # wildcard: *@example.com, director*, finance*
|
||||
$ExcludeMailboxPatterns = @('HealthMailbox*', 'DiscoveryMailbox*', 'SystemMailbox*')
|
||||
# Skip Inbox rules scan (corrupt rule store / Get-InboxRule fails). Override in exchange_monitor.settings.ps1
|
||||
$SkipInboxScanMailboxes = @(
|
||||
'broken-mailbox@example.com'
|
||||
)
|
||||
$ScanDisabledInboxRulesWithExternalForward = $true # отключённые правила с внешней пересылкой
|
||||
$SuppressAlertsOnFirstBaselineRun = $true # первый скан: baseline без всплеска алертов
|
||||
$SendInboxScanSummary = $true # краткая сводка после скана
|
||||
$InboxScanBatchSize = 50
|
||||
$InboxScanBatchDelaySeconds = 3
|
||||
$MaxMailboxesPerRun = 0 # 0 = без лимита
|
||||
$QueuesHeartbeatStaleSeconds = 1200 # 20 мин
|
||||
$InboxHeartbeatStaleSeconds = 93600 # 26 ч
|
||||
$NotifyWhenForwardingScanClean = $false # true = ежедневное «внешняя пересылка не найдена»
|
||||
|
||||
$script:ScheduledTaskQueues = 'RDP-Exchange-TransportQueues'
|
||||
$script:ScheduledTaskInbox = 'RDP-Exchange-InboxRules'
|
||||
$script:ScheduledTaskWatchdog = 'RDP-Exchange-MailSecurity-Watchdog'
|
||||
|
||||
# ============================================
|
||||
# ИНИЦИАЛИЗАЦИЯ
|
||||
# ============================================
|
||||
|
||||
if (!(Test-Path -LiteralPath $script:InstallRoot)) {
|
||||
New-Item -ItemType Directory -Path $script:InstallRoot -Force | Out-Null
|
||||
}
|
||||
$LogDir = Split-Path $LogFile -Parent
|
||||
if (!(Test-Path $LogDir)) { New-Item -ItemType Directory -Path $LogDir -Force | Out-Null }
|
||||
|
||||
$script:Utf8BomEncoding = New-Object System.Text.UTF8Encoding $true
|
||||
|
||||
$SettingsFile = Join-Path $script:InstallRoot 'exchange_monitor.settings.ps1'
|
||||
if (Test-Path -LiteralPath $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 {
|
||||
param([string]$Message)
|
||||
Write-ExchLog $Message
|
||||
}
|
||||
|
||||
function Write-ExchLog {
|
||||
param([string]$Message)
|
||||
$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
|
||||
$logMessage = "$timestamp - $Message" + [Environment]::NewLine
|
||||
[System.IO.File]::AppendAllText($LogFile, $logMessage, $script:Utf8BomEncoding)
|
||||
Write-Host ($logMessage.TrimEnd("`r`n"))
|
||||
}
|
||||
|
||||
function Write-WdLog {
|
||||
param([string]$Message)
|
||||
$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
|
||||
$line = "$timestamp - $Message" + [Environment]::NewLine
|
||||
[System.IO.File]::AppendAllText($WatchdogLogFile, $line, $script:Utf8BomEncoding)
|
||||
Write-Host ($line.TrimEnd("`r`n"))
|
||||
}
|
||||
|
||||
function Test-RunningElevated {
|
||||
try {
|
||||
$id = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
if ($null -ne $id.User -and $id.User.Value -eq 'S-1-5-18') { return $true }
|
||||
$p = New-Object Security.Principal.WindowsPrincipal($id)
|
||||
return $p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
} catch { return $false }
|
||||
}
|
||||
|
||||
function Get-ExchangeMonitorScriptPath {
|
||||
$p = $PSCommandPath
|
||||
if ([string]::IsNullOrWhiteSpace($p)) { $p = $MyInvocation.MyCommand.Path }
|
||||
if ([string]::IsNullOrWhiteSpace($p)) { return (Join-Path $script:InstallRoot $script:CanonicalScriptName) }
|
||||
return [System.IO.Path]::GetFullPath($p)
|
||||
}
|
||||
|
||||
function Get-ExchangeMonitorPowerShellExe {
|
||||
return "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe"
|
||||
}
|
||||
|
||||
# --- InstallTasks / Watchdog до dot-source уведомлений ---
|
||||
|
||||
function Register-ExchangeMonitorScheduledTasks {
|
||||
$psExe = Get-ExchangeMonitorPowerShellExe
|
||||
$scriptPath = Get-ExchangeMonitorScriptPath
|
||||
$argQueues = "-NoProfile -ExecutionPolicy Bypass -File `"$scriptPath`" -Mode Queues"
|
||||
$argInbox = "-NoProfile -ExecutionPolicy Bypass -File `"$scriptPath`" -Mode Inbox"
|
||||
$argWd = "-NoProfile -ExecutionPolicy Bypass -File `"$scriptPath`" -Watchdog"
|
||||
|
||||
$schtasksExe = Join-Path $env:SystemRoot 'System32\schtasks.exe'
|
||||
$delEa = $ErrorActionPreference
|
||||
$ErrorActionPreference = 'SilentlyContinue'
|
||||
|
||||
foreach ($tn in @($script:ScheduledTaskQueues, $script:ScheduledTaskInbox, $script:ScheduledTaskWatchdog)) {
|
||||
& $schtasksExe /Delete /TN $tn /F 2>$null | Out-Null
|
||||
}
|
||||
$ErrorActionPreference = $delEa
|
||||
|
||||
$trQueues = "`"$psExe`" $argQueues"
|
||||
$outQ = & $schtasksExe /Create /F /RU SYSTEM /RL HIGHEST /SC MINUTE /MO 10 /TN $script:ScheduledTaskQueues /TR $trQueues 2>&1
|
||||
foreach ($line in @($outQ)) { Write-ExchLog "schtasks queues: $line" }
|
||||
|
||||
$trInbox = "`"$psExe`" $argInbox"
|
||||
$outI = & $schtasksExe /Create /F /RU SYSTEM /RL HIGHEST /SC DAILY /ST 02:00 /TN $script:ScheduledTaskInbox /TR $trInbox 2>&1
|
||||
foreach ($line in @($outI)) { Write-ExchLog "schtasks inbox: $line" }
|
||||
|
||||
$trWd = "`"$psExe`" $argWd"
|
||||
$outW = & $schtasksExe /Create /F /RU SYSTEM /RL HIGHEST /SC MINUTE /MO 15 /TN $script:ScheduledTaskWatchdog /TR $trWd 2>&1
|
||||
foreach ($line in @($outW)) { Write-ExchLog "schtasks watchdog: $line" }
|
||||
|
||||
$ErrorActionPreference = 'SilentlyContinue'
|
||||
& $schtasksExe /Run /TN $script:ScheduledTaskQueues 2>&1 | Out-Null
|
||||
& $schtasksExe /Run /TN $script:ScheduledTaskWatchdog 2>&1 | Out-Null
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
Write-ExchLog "InstallTasks: registered $($script:ScheduledTaskQueues), $($script:ScheduledTaskInbox), $($script:ScheduledTaskWatchdog)"
|
||||
}
|
||||
|
||||
if ($Watchdog) { $Mode = 'Watchdog' }
|
||||
|
||||
# Уведомления
|
||||
$notifyPath = Join-Path $script:InstallRoot 'Notify-Common.ps1'
|
||||
if (-not (Test-Path -LiteralPath $notifyPath)) {
|
||||
$notifyPath = Join-Path (Split-Path -Parent (Get-ExchangeMonitorScriptPath)) 'Notify-Common.ps1'
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $notifyPath)) {
|
||||
Write-Host "Notify-Common.ps1 not found near $script:InstallRoot"
|
||||
exit 1
|
||||
}
|
||||
. $notifyPath
|
||||
|
||||
$tgToken = [ref]$TelegramBotToken
|
||||
$tgChat = [ref]$TelegramChatID
|
||||
$mailPass = [ref]$MailSmtpPassword
|
||||
Initialize-NotifyCredentials -TelegramBotTokenProtectedB64 $TelegramBotTokenProtectedB64 `
|
||||
-TelegramChatIDProtectedB64 $TelegramChatIDProtectedB64 `
|
||||
-TelegramBotToken $tgToken -TelegramChatID $tgChat `
|
||||
-MailSmtpPasswordProtectedB64 $MailSmtpPasswordProtectedB64 -MailSmtpPassword $mailPass
|
||||
if ($TelegramBotToken -eq '<TELEGRAM_BOT_TOKEN>') { $TelegramBotToken = '' }
|
||||
if ($TelegramChatID -eq '<TELEGRAM_CHAT_ID>') { $TelegramChatID = '' }
|
||||
|
||||
function Get-ExchangeInboxScanScopeLabel {
|
||||
if (-not $VipMailboxesOnly) {
|
||||
if ($MaxMailboxesPerRun -gt 0) { return "all mailboxes (limit $MaxMailboxesPerRun)" }
|
||||
return 'all mailboxes'
|
||||
}
|
||||
$n = @($VipMailboxes | Where-Object { $_ }).Count
|
||||
$p = @($VipMailboxPatterns | Where-Object { $_ }).Count
|
||||
return "VIP list=$n patterns=$p"
|
||||
}
|
||||
|
||||
function Send-ExchangeInstallNotification {
|
||||
$scope = Get-ExchangeInboxScanScopeLabel
|
||||
$vipNote = if ($VipMailboxesOnly) { "VIP on ($scope)" } else { 'VIP off (full scan)' }
|
||||
$firstScan = if ($SuppressAlertsOnFirstBaselineRun) { 'baseline, no alert flood on first run' } else { 'alert on all findings' }
|
||||
$msg = "<b>Exchange Mail Security installed</b>`r`n"
|
||||
$msg += "Host: $(ConvertTo-TelegramHtml $env:COMPUTERNAME) | v$ScriptVersion`r`n"
|
||||
$msg += "Notify: $(ConvertTo-TelegramHtml (Get-NotifyChainHuman))`r`n"
|
||||
$msg += "Inbox/forward: $vipNote`r`n"
|
||||
$msg += "Queues threshold: $QueueMessageCountThreshold | Inbox 02:00 | Queues every 10 min`r`n"
|
||||
$msg += "First scan: $firstScan"
|
||||
Send-MonitorNotification -Message $msg -EmailSubject 'Exchange Mail Security: install' | Out-Null
|
||||
}
|
||||
|
||||
if ($InstallTasks) {
|
||||
if (-not (Test-RunningElevated)) {
|
||||
Write-Host 'InstallTasks: run as Administrator.'
|
||||
exit 1
|
||||
}
|
||||
Register-ExchangeMonitorScheduledTasks
|
||||
if ((Test-NotifyTelegramConfigured) -or (Test-NotifyEmailConfigured)) {
|
||||
Send-ExchangeInstallNotification
|
||||
Write-ExchLog 'InstallTasks: install notification sent'
|
||||
}
|
||||
Write-ExchLog 'InstallTasks: перед первым ночным Inbox-сканом выполните: Exchange-MailSecurity.ps1 -Mode Inbox -WhatIf'
|
||||
exit 0
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# EXCHANGE EMS
|
||||
# ============================================
|
||||
|
||||
$script:ExchangeSessionLoaded = $false
|
||||
|
||||
function Import-ExchangeManagementShell {
|
||||
if ($script:ExchangeSessionLoaded) { return }
|
||||
|
||||
$snapins = @(Get-PSSnapin -Registered -ErrorAction SilentlyContinue | Where-Object {
|
||||
$_.Name -match 'Microsoft\.Exchange\.Management\.PowerShell'
|
||||
})
|
||||
if ($snapins.Count -gt 0) {
|
||||
Add-PSSnapin -Name $snapins[0].Name -ErrorAction Stop
|
||||
$script:ExchangeSessionLoaded = $true
|
||||
Write-ExchLog "Exchange: snap-in $($snapins[0].Name)"
|
||||
return
|
||||
}
|
||||
|
||||
$rex = Join-Path ${env:ExchangeInstallPath} 'bin\RemoteExchange.ps1'
|
||||
if ([string]::IsNullOrWhiteSpace(${env:ExchangeInstallPath})) {
|
||||
$candidates = @(
|
||||
'C:\Program Files\Microsoft\Exchange Server\V15\bin\RemoteExchange.ps1',
|
||||
'C:\Program Files\Microsoft\Exchange Server\V14\bin\RemoteExchange.ps1'
|
||||
)
|
||||
foreach ($c in $candidates) {
|
||||
if (Test-Path -LiteralPath $c) { $rex = $c; break }
|
||||
}
|
||||
}
|
||||
if (Test-Path -LiteralPath $rex) {
|
||||
. $rex
|
||||
Import-ExchangeCmdlet -DisableNameVerifying -ErrorAction Stop
|
||||
$script:ExchangeSessionLoaded = $true
|
||||
Write-ExchLog "Exchange: RemoteExchange $rex"
|
||||
return
|
||||
}
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($ExchangeServerFqdn)) {
|
||||
$uri = "http://$ExchangeServerFqdn/PowerShell/"
|
||||
$session = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri $uri -Authentication Kerberos -ErrorAction Stop
|
||||
Import-PSSession $session -DisableNameChecking -AllowClobber -ErrorAction Stop | Out-Null
|
||||
$script:ExchangeSessionLoaded = $true
|
||||
Write-ExchLog "Exchange: PSSession $uri"
|
||||
return
|
||||
}
|
||||
|
||||
throw 'Failed to load Exchange Management Shell (snap-in / RemoteExchange / set $ExchangeServerFqdn).'
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# ВНЕШНИЕ ДОМЕНЫ
|
||||
# ============================================
|
||||
|
||||
function Get-InternalAcceptedDomainNames {
|
||||
$null = Import-ExchangeManagementShell
|
||||
$names = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
|
||||
$domains = Get-AcceptedDomain -ErrorAction Stop
|
||||
foreach ($d in $domains) {
|
||||
if ($d.DomainType -eq 'Authoritative' -or $d.DomainType -eq 'InternalRelay') {
|
||||
$null = $names.Add([string]$d.DomainName)
|
||||
}
|
||||
}
|
||||
return $names
|
||||
}
|
||||
|
||||
function Get-EmailDomainFromAddress {
|
||||
param([string]$Address)
|
||||
if ([string]::IsNullOrWhiteSpace($Address)) { return $null }
|
||||
$a = $Address.Trim().ToLowerInvariant()
|
||||
if ($a -match 'smtp:([^@\s]+@([^@\s;]+))') { return $matches[2] }
|
||||
if ($a -match '([a-z0-9._%+-]+@([a-z0-9.-]+\.[a-z]{2,}))') { return $matches[2] }
|
||||
return $null
|
||||
}
|
||||
|
||||
function Get-AddressesFromExchangeProperty {
|
||||
param($PropertyValue)
|
||||
$list = [System.Collections.Generic.List[string]]::new()
|
||||
if ($null -eq $PropertyValue) { return @() }
|
||||
foreach ($item in @($PropertyValue)) {
|
||||
$s = [string]$item
|
||||
if ([string]::IsNullOrWhiteSpace($s)) { continue }
|
||||
if ($s -match 'smtp:([^;\s]+@[^;\s]+)') {
|
||||
$list.Add($matches[1].ToLowerInvariant()) | Out-Null
|
||||
} elseif ($s -match '([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})') {
|
||||
$list.Add($matches[1].ToLowerInvariant()) | Out-Null
|
||||
}
|
||||
}
|
||||
return @($list)
|
||||
}
|
||||
|
||||
function Test-IsExternalSmtpAddress {
|
||||
param(
|
||||
[string]$SmtpAddress,
|
||||
[System.Collections.Generic.HashSet[string]]$InternalDomains,
|
||||
[string[]]$WhitelistDomains
|
||||
)
|
||||
|
||||
$domain = Get-EmailDomainFromAddress -Address $SmtpAddress
|
||||
if ([string]::IsNullOrWhiteSpace($domain)) { return $false }
|
||||
|
||||
foreach ($w in $WhitelistDomains) {
|
||||
if ([string]::IsNullOrWhiteSpace($w)) { continue }
|
||||
if ($domain -eq $w.Trim().ToLowerInvariant()) { return $false }
|
||||
}
|
||||
|
||||
return -not $InternalDomains.Contains($domain)
|
||||
}
|
||||
|
||||
function Test-MailboxExcluded {
|
||||
param([string]$PrimarySmtp)
|
||||
foreach ($pat in $ExcludeMailboxPatterns) {
|
||||
if ([string]::IsNullOrWhiteSpace($pat)) { continue }
|
||||
if ($PrimarySmtp -like $pat) { return $true }
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
function Test-MailboxSkipInboxScan {
|
||||
param([string]$PrimarySmtp)
|
||||
if ([string]::IsNullOrWhiteSpace($PrimarySmtp)) { return $false }
|
||||
$smtp = $PrimarySmtp.Trim().ToLowerInvariant()
|
||||
foreach ($skip in $SkipInboxScanMailboxes) {
|
||||
if ([string]::IsNullOrWhiteSpace($skip)) { continue }
|
||||
if ($smtp -eq $skip.Trim().ToLowerInvariant()) { return $true }
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
function Test-VipMailboxScopeConfigured {
|
||||
if (-not $VipMailboxesOnly) { return $true }
|
||||
$hasList = @($VipMailboxes | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }).Count -gt 0
|
||||
$hasPat = @($VipMailboxPatterns | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }).Count -gt 0
|
||||
return ($hasList -or $hasPat)
|
||||
}
|
||||
|
||||
function Test-MailboxInVipScope {
|
||||
param([string]$PrimarySmtp)
|
||||
|
||||
if (-not $VipMailboxesOnly) { return $true }
|
||||
if (-not (Test-VipMailboxScopeConfigured)) { return $false }
|
||||
|
||||
$smtp = $PrimarySmtp.Trim().ToLowerInvariant()
|
||||
foreach ($v in $VipMailboxes) {
|
||||
if ([string]::IsNullOrWhiteSpace($v)) { continue }
|
||||
if ($smtp -eq $v.Trim().ToLowerInvariant()) { return $true }
|
||||
}
|
||||
foreach ($pat in $VipMailboxPatterns) {
|
||||
if ([string]::IsNullOrWhiteSpace($pat)) { continue }
|
||||
if ($smtp -like $pat) { return $true }
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# FINDINGS
|
||||
# ============================================
|
||||
|
||||
function New-ForwardingFinding {
|
||||
param(
|
||||
[string]$FindingType,
|
||||
[string]$Mailbox,
|
||||
[string]$RuleName,
|
||||
[string]$TargetAddress,
|
||||
[string]$Severity,
|
||||
[hashtable]$Extra = @{}
|
||||
)
|
||||
|
||||
$rulePart = if ([string]::IsNullOrWhiteSpace($RuleName)) { '' } else { $RuleName }
|
||||
$enabledPart = 'na'
|
||||
if ($Extra.ContainsKey('Enabled')) {
|
||||
$enabledPart = if ($Extra['Enabled']) { 'on' } else { 'off' }
|
||||
}
|
||||
$id = '{0}|{1}|{2}|{3}|{4}' -f $FindingType, $Mailbox.ToLowerInvariant(), $rulePart, $TargetAddress.ToLowerInvariant(), $enabledPart
|
||||
return [pscustomobject]@{
|
||||
Id = $id
|
||||
FindingType = $FindingType
|
||||
Mailbox = $Mailbox
|
||||
RuleName = $RuleName
|
||||
TargetAddress = $TargetAddress
|
||||
Severity = $Severity
|
||||
Extra = $Extra
|
||||
}
|
||||
}
|
||||
|
||||
function Format-ForwardingFindingMessage {
|
||||
param($Finding)
|
||||
|
||||
$hType = ConvertTo-TelegramHtml $Finding.FindingType
|
||||
$hMb = ConvertTo-TelegramHtml $Finding.Mailbox
|
||||
$hRule = ConvertTo-TelegramHtml $Finding.RuleName
|
||||
$hTarget = ConvertTo-TelegramHtml $Finding.TargetAddress
|
||||
$hSev = ConvertTo-TelegramHtml $Finding.Severity
|
||||
$hostName = ConvertTo-TelegramHtml $env:COMPUTERNAME
|
||||
|
||||
$msg = "<b>Exchange: external forward</b>`r`n"
|
||||
$msg += "Host: $hostName`r`n"
|
||||
$msg += "Type: $hType`r`n"
|
||||
$msg += "Mailbox: $hMb`r`n"
|
||||
if (-not [string]::IsNullOrWhiteSpace($Finding.RuleName)) {
|
||||
$msg += "Rule: $hRule`r`n"
|
||||
}
|
||||
$msg += "Target: $hTarget (external)`r`n"
|
||||
$msg += "Severity: $hSev`r`n"
|
||||
|
||||
if ($Finding.Extra.ContainsKey('Enabled')) {
|
||||
$msg += "Enabled: $(ConvertTo-TelegramHtml ([string]$Finding.Extra['Enabled']))`r`n"
|
||||
}
|
||||
if ($Finding.Extra.ContainsKey('DeleteMessage')) {
|
||||
$msg += "DeleteMessage: $(ConvertTo-TelegramHtml ([string]$Finding.Extra['DeleteMessage']))`r`n"
|
||||
}
|
||||
if ($Finding.Extra.ContainsKey('MarkAsRead')) {
|
||||
$msg += "MarkAsRead: $(ConvertTo-TelegramHtml ([string]$Finding.Extra['MarkAsRead']))`r`n"
|
||||
}
|
||||
if ($Finding.Extra.ContainsKey('DeliverToMailboxAndForward')) {
|
||||
$msg += "DeliverToMailboxAndForward: $(ConvertTo-TelegramHtml ([string]$Finding.Extra['DeliverToMailboxAndForward']))`r`n"
|
||||
}
|
||||
if ($Finding.Extra.ContainsKey('Property')) {
|
||||
$msg += "Rule property: $(ConvertTo-TelegramHtml ([string]$Finding.Extra['Property']))`r`n"
|
||||
}
|
||||
|
||||
return $msg
|
||||
}
|
||||
|
||||
function Get-ForwardingBaseline {
|
||||
if (-not (Test-Path -LiteralPath $ForwardingBaselineFile)) {
|
||||
return @{ FindingIds = @(); LastScanUtc = $null }
|
||||
}
|
||||
try {
|
||||
$raw = Get-Content -LiteralPath $ForwardingBaselineFile -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
$ids = @()
|
||||
if ($null -ne $raw.FindingIds) { $ids = @($raw.FindingIds) }
|
||||
return @{ FindingIds = $ids; LastScanUtc = $raw.LastScanUtc }
|
||||
} catch {
|
||||
Write-ExchLog "Baseline: read failed ($($_.Exception.Message))"
|
||||
return @{ FindingIds = @(); LastScanUtc = $null }
|
||||
}
|
||||
}
|
||||
|
||||
function Save-ForwardingBaseline {
|
||||
param([string[]]$FindingIds)
|
||||
$obj = @{
|
||||
LastScanUtc = (Get-Date).ToUniversalTime().ToString('o')
|
||||
FindingIds = @($FindingIds)
|
||||
}
|
||||
$json = $obj | ConvertTo-Json -Depth 4
|
||||
Write-TextFileUtf8Bom -Path $ForwardingBaselineFile -Text $json
|
||||
}
|
||||
|
||||
function Write-TextFileUtf8Bom {
|
||||
param([string]$Path, [string]$Text)
|
||||
[System.IO.File]::WriteAllText($Path, $Text, $script:Utf8BomEncoding)
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# SCAN: QUEUES
|
||||
# ============================================
|
||||
|
||||
function Get-QueueAlertState {
|
||||
if (-not (Test-Path -LiteralPath $QueueAlertStateFile)) { return @{} }
|
||||
try {
|
||||
$h = @{}
|
||||
(Get-Content -LiteralPath $QueueAlertStateFile -Raw -Encoding UTF8 | ConvertFrom-Json).PSObject.Properties | ForEach-Object {
|
||||
$h[$_.Name] = [datetime]$_.Value
|
||||
}
|
||||
return $h
|
||||
} catch { return @{} }
|
||||
}
|
||||
|
||||
function Save-QueueAlertState {
|
||||
param([hashtable]$State)
|
||||
$obj = @{}
|
||||
foreach ($k in $State.Keys) { $obj[$k] = $State[$k].ToUniversalTime().ToString('o') }
|
||||
Write-TextFileUtf8Bom -Path $QueueAlertStateFile -Text ($obj | ConvertTo-Json)
|
||||
}
|
||||
|
||||
function Invoke-ExchangeQueueScan {
|
||||
$null = Import-ExchangeManagementShell
|
||||
Write-ExchLog "Queues: threshold MessageCount > $QueueMessageCountThreshold"
|
||||
|
||||
$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 })
|
||||
$state = Get-QueueAlertState
|
||||
$now = Get-Date
|
||||
|
||||
if ($hot.Count -eq 0) {
|
||||
Write-ExchLog "Queues: OK (total $($queues.Count), above threshold 0)"
|
||||
Write-TextFileUtf8Bom -Path $QueuesHeartbeatFile -Text (Get-Date -Format 'yyyy-MM-dd HH:mm:ss')
|
||||
return
|
||||
}
|
||||
|
||||
foreach ($q in $hot) {
|
||||
$qKey = [string]$q.Identity
|
||||
$lastAlert = $null
|
||||
if ($state.ContainsKey($qKey)) { $lastAlert = $state[$qKey] }
|
||||
$cooldownOk = ($null -eq $lastAlert) -or (((Get-Date).ToUniversalTime() - $lastAlert.ToUniversalTime()).TotalSeconds -ge $QueueAlertCooldownSeconds)
|
||||
if (-not $cooldownOk) {
|
||||
Write-ExchLog "Queues: $($q.Identity) count=$($q.MessageCount) - cooldown, skip alert"
|
||||
continue
|
||||
}
|
||||
|
||||
$hId = ConvertTo-TelegramHtml $q.Identity
|
||||
$msg = "<b>Exchange: transport queue</b>`r`n"
|
||||
$msg += "Host: $(ConvertTo-TelegramHtml $env:COMPUTERNAME)`r`n"
|
||||
$msg += "Queue: $hId`r`n"
|
||||
$msg += "Messages: <b>$($q.MessageCount)</b> (threshold $QueueMessageCountThreshold)`r`n"
|
||||
$msg += "Status: $(ConvertTo-TelegramHtml ([string]$q.Status))`r`n"
|
||||
$msg += "Time: $(ConvertTo-TelegramHtml (Get-Date -Format 'dd.MM.yyyy HH:mm:ss'))"
|
||||
|
||||
if (Send-MonitorNotification -Message $msg -EmailSubject 'Exchange: transport queue') {
|
||||
$state[$qKey] = $now
|
||||
Write-ExchLog "Queues: alert sent for $($q.Identity)"
|
||||
}
|
||||
}
|
||||
|
||||
Save-QueueAlertState -State $state
|
||||
Write-TextFileUtf8Bom -Path $QueuesHeartbeatFile -Text (Get-Date -Format 'yyyy-MM-dd HH:mm:ss')
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# SCAN: FORWARDING
|
||||
# ============================================
|
||||
|
||||
function Get-MailboxListForScan {
|
||||
$null = Import-ExchangeManagementShell
|
||||
|
||||
if ($VipMailboxesOnly) {
|
||||
if (-not (Test-VipMailboxScopeConfigured)) {
|
||||
throw 'VipMailboxesOnly=$true but VipMailboxes and VipMailboxPatterns are empty - set them in exchange_monitor.settings.ps1'
|
||||
}
|
||||
$set = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
|
||||
foreach ($v in $VipMailboxes) {
|
||||
if ([string]::IsNullOrWhiteSpace($v)) { continue }
|
||||
if (Test-MailboxSkipInboxScan -PrimarySmtp $v) { continue }
|
||||
$null = $set.Add($v.Trim().ToLowerInvariant())
|
||||
}
|
||||
if ($VipMailboxPatterns.Count -gt 0) {
|
||||
$all = @(Get-Mailbox -ResultSize Unlimited -ErrorAction Stop)
|
||||
foreach ($mb in $all) {
|
||||
$smtp = [string]$mb.PrimarySmtpAddress
|
||||
if ([string]::IsNullOrWhiteSpace($smtp)) { continue }
|
||||
if (Test-MailboxExcluded -PrimarySmtp $smtp) { continue }
|
||||
if (Test-MailboxSkipInboxScan -PrimarySmtp $smtp) { continue }
|
||||
if (Test-MailboxInVipScope -PrimarySmtp $smtp) {
|
||||
$null = $set.Add($smtp.ToLowerInvariant())
|
||||
}
|
||||
}
|
||||
}
|
||||
return @($set)
|
||||
}
|
||||
|
||||
$all = @(Get-Mailbox -ResultSize Unlimited -ErrorAction Stop)
|
||||
$list = [System.Collections.Generic.List[string]]::new()
|
||||
foreach ($mb in $all) {
|
||||
$smtp = [string]$mb.PrimarySmtpAddress
|
||||
if ([string]::IsNullOrWhiteSpace($smtp)) { continue }
|
||||
if (Test-MailboxExcluded -PrimarySmtp $smtp) { continue }
|
||||
if (Test-MailboxSkipInboxScan -PrimarySmtp $smtp) { continue }
|
||||
$list.Add($smtp.ToLowerInvariant()) | Out-Null
|
||||
if ($MaxMailboxesPerRun -gt 0 -and $list.Count -ge $MaxMailboxesPerRun) { break }
|
||||
}
|
||||
return @($list)
|
||||
}
|
||||
|
||||
function Scan-MailboxForwardingSettings {
|
||||
param(
|
||||
[System.Collections.Generic.HashSet[string]]$InternalDomains,
|
||||
[System.Collections.Generic.List[object]]$Findings
|
||||
)
|
||||
|
||||
if (-not $ScanMailboxForwarding) { return }
|
||||
|
||||
Write-ExchLog 'Forwarding: Get-Mailbox (ForwardingSmtpAddress / ForwardingAddress)'
|
||||
$mbs = Get-Mailbox -ResultSize Unlimited -ErrorAction Stop | Where-Object {
|
||||
$_.ForwardingSmtpAddress -or $_.ForwardingAddress
|
||||
}
|
||||
|
||||
foreach ($mb in $mbs) {
|
||||
$primary = [string]$mb.PrimarySmtpAddress
|
||||
if (Test-MailboxExcluded -PrimarySmtp $primary) { continue }
|
||||
if (-not (Test-MailboxInVipScope -PrimarySmtp $primary)) { continue }
|
||||
|
||||
$targets = @()
|
||||
if ($mb.ForwardingSmtpAddress) {
|
||||
$targets += @($mb.ForwardingSmtpAddress)
|
||||
}
|
||||
if ($mb.ForwardingAddress) {
|
||||
$targets += @((Get-AddressesFromExchangeProperty -PropertyValue $mb.ForwardingAddress))
|
||||
}
|
||||
|
||||
foreach ($t in $targets) {
|
||||
if (-not (Test-IsExternalSmtpAddress -SmtpAddress $t -InternalDomains $InternalDomains -WhitelistDomains $ExternalDomainWhitelist)) {
|
||||
continue
|
||||
}
|
||||
$sev = 'High'
|
||||
$Findings.Add((New-ForwardingFinding -FindingType 'MailboxForwarding' -Mailbox $primary `
|
||||
-RuleName '' -TargetAddress $t -Severity $sev -Extra @{
|
||||
DeliverToMailboxAndForward = [string]$mb.DeliverToMailboxAndForward
|
||||
})) | Out-Null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Add-InboxRuleExternalForwardFindings {
|
||||
param(
|
||||
$Rule,
|
||||
[string]$Mailbox,
|
||||
[System.Collections.Generic.HashSet[string]]$InternalDomains,
|
||||
[System.Collections.Generic.List[object]]$Findings
|
||||
)
|
||||
|
||||
$props = @(
|
||||
@{ Name = 'ForwardTo'; Value = $Rule.ForwardTo }
|
||||
@{ Name = 'RedirectTo'; Value = $Rule.RedirectTo }
|
||||
@{ Name = 'ForwardAsAttachmentTo'; Value = $Rule.ForwardAsAttachmentTo }
|
||||
)
|
||||
|
||||
foreach ($p in $props) {
|
||||
$addrs = Get-AddressesFromExchangeProperty -PropertyValue $p.Value
|
||||
foreach ($addr in $addrs) {
|
||||
if (-not (Test-IsExternalSmtpAddress -SmtpAddress $addr -InternalDomains $InternalDomains -WhitelistDomains $ExternalDomainWhitelist)) {
|
||||
continue
|
||||
}
|
||||
$sev = 'High'
|
||||
if (-not $Rule.Enabled) {
|
||||
$sev = 'Medium (rule disabled)'
|
||||
} elseif ($Rule.DeleteMessage -or $Rule.MarkAsRead) {
|
||||
$sev = 'Critical'
|
||||
}
|
||||
$Findings.Add((New-ForwardingFinding -FindingType 'InboxRule' -Mailbox $Mailbox `
|
||||
-RuleName [string]$Rule.Name -TargetAddress $addr -Severity $sev -Extra @{
|
||||
Enabled = [bool]$Rule.Enabled
|
||||
DeleteMessage = $Rule.DeleteMessage
|
||||
MarkAsRead = $Rule.MarkAsRead
|
||||
Property = $p.Name
|
||||
})) | Out-Null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Scan-InboxRulesForMailbox {
|
||||
param(
|
||||
[string]$Mailbox,
|
||||
[System.Collections.Generic.HashSet[string]]$InternalDomains,
|
||||
[System.Collections.Generic.List[object]]$Findings
|
||||
)
|
||||
|
||||
if (Test-MailboxSkipInboxScan -PrimarySmtp $Mailbox) {
|
||||
Write-ExchLog "Inbox rules: SKIP $Mailbox (SkipInboxScanMailboxes)"
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
$rules = @(Get-InboxRule -Mailbox $Mailbox -ErrorAction Stop)
|
||||
} catch {
|
||||
Write-ExchLog "Inbox rules: SKIP $Mailbox (Get-InboxRule failed: $($_.Exception.Message))"
|
||||
return
|
||||
}
|
||||
|
||||
foreach ($rule in $rules) {
|
||||
if ($rule.Enabled) {
|
||||
Add-InboxRuleExternalForwardFindings -Rule $rule -Mailbox $Mailbox -InternalDomains $InternalDomains -Findings $Findings
|
||||
} elseif ($ScanDisabledInboxRulesWithExternalForward) {
|
||||
Add-InboxRuleExternalForwardFindings -Rule $rule -Mailbox $Mailbox -InternalDomains $InternalDomains -Findings $Findings
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Scan-TransportRulesExternalForward {
|
||||
param(
|
||||
[System.Collections.Generic.HashSet[string]]$InternalDomains,
|
||||
[System.Collections.Generic.List[object]]$Findings
|
||||
)
|
||||
|
||||
if (-not $ScanTransportRules) { return }
|
||||
|
||||
Write-ExchLog 'Forwarding: Get-TransportRule'
|
||||
$rules = @(Get-TransportRule -ErrorAction SilentlyContinue | Where-Object { $_.State -eq 'Enabled' })
|
||||
foreach ($rule in $rules) {
|
||||
$props = @(
|
||||
$rule.RedirectMessageTo
|
||||
$rule.BlindCopyTo
|
||||
$rule.CopyTo
|
||||
)
|
||||
foreach ($pv in $props) {
|
||||
$addrs = Get-AddressesFromExchangeProperty -PropertyValue $pv
|
||||
foreach ($addr in $addrs) {
|
||||
if (-not (Test-IsExternalSmtpAddress -SmtpAddress $addr -InternalDomains $InternalDomains -WhitelistDomains $ExternalDomainWhitelist)) {
|
||||
continue
|
||||
}
|
||||
$Findings.Add((New-ForwardingFinding -FindingType 'TransportRule' -Mailbox '(transport)' `
|
||||
-RuleName [string]$rule.Name -TargetAddress $addr -Severity 'High' -Extra @{})) | Out-Null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Send-ExchangeInboxScanSummary {
|
||||
param(
|
||||
[int]$TotalFindings,
|
||||
[int]$NewFindings,
|
||||
[int]$MailboxCount,
|
||||
[bool]$FirstBaselineSeeded,
|
||||
[string]$ScopeLabel
|
||||
)
|
||||
|
||||
if (-not $SendInboxScanSummary) { return }
|
||||
|
||||
$hHost = ConvertTo-TelegramHtml $env:COMPUTERNAME
|
||||
$msg = "<b>Exchange: forwarding scan summary</b>`r`n"
|
||||
$msg += "Host: $hHost | v$ScriptVersion`r`n"
|
||||
$msg += "Scope: $(ConvertTo-TelegramHtml $ScopeLabel)`r`n"
|
||||
$msg += "Mailboxes (Inbox rules): $MailboxCount`r`n"
|
||||
$msg += "Findings total: $TotalFindings | new: $NewFindings`r`n"
|
||||
if ($FirstBaselineSeeded) {
|
||||
$msg += "<i>First baseline: existing forwards suppressed (SuppressAlertsOnFirstBaselineRun).</i>`r`n"
|
||||
}
|
||||
Send-MonitorNotification -Message $msg -EmailSubject 'Exchange: scan summary' | Out-Null
|
||||
}
|
||||
|
||||
function Invoke-ExchangeInboxAndForwardingScan {
|
||||
$scopeLabel = Get-ExchangeInboxScanScopeLabel
|
||||
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
|
||||
$internalDomains = Get-InternalAcceptedDomainNames
|
||||
Write-ExchLog "Accepted domains (internal): $(@($internalDomains) -join ', ')"
|
||||
|
||||
$findings = [System.Collections.Generic.List[object]]::new()
|
||||
|
||||
Scan-MailboxForwardingSettings -InternalDomains $internalDomains -Findings $findings
|
||||
|
||||
$mailboxCount = 0
|
||||
if ($ScanInboxRules) {
|
||||
$mailboxes = @(Get-MailboxListForScan)
|
||||
$mailboxCount = $mailboxes.Count
|
||||
$skipList = @($SkipInboxScanMailboxes | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
|
||||
if ($skipList.Count -gt 0) {
|
||||
Write-ExchLog "Inbox rules: SkipInboxScanMailboxes: $($skipList -join ', ')"
|
||||
}
|
||||
Write-ExchLog "Inbox rules: mailboxes to scan: $mailboxCount ($scopeLabel)"
|
||||
$idx = 0
|
||||
foreach ($mb in $mailboxes) {
|
||||
$idx++
|
||||
Scan-InboxRulesForMailbox -Mailbox $mb -InternalDomains $internalDomains -Findings $findings
|
||||
if ($idx % $InboxScanBatchSize -eq 0) {
|
||||
Write-ExchLog "Inbox rules: processed $idx / $($mailboxes.Count)"
|
||||
Start-Sleep -Seconds $InboxScanBatchDelaySeconds
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Scan-TransportRulesExternalForward -InternalDomains $internalDomains -Findings $findings
|
||||
|
||||
$allIds = @($findings | ForEach-Object { $_.Id })
|
||||
$baseline = Get-ForwardingBaseline
|
||||
$prevSet = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
|
||||
foreach ($id in $baseline.FindingIds) { $null = $prevSet.Add($id) }
|
||||
|
||||
$newFindings = @($findings | Where-Object { -not $prevSet.Contains($_.Id) })
|
||||
Write-ExchLog "Forwarding: total $($findings.Count), new $($newFindings.Count)"
|
||||
|
||||
$isFirstBaseline = ($baseline.FindingIds.Count -eq 0) -and ($findings.Count -gt 0)
|
||||
$firstBaselineSeeded = $false
|
||||
|
||||
$toAlert = if ($AlertOnlyOnNewForwardingFindings) { $newFindings } else { @($findings) }
|
||||
if ($SuppressAlertsOnFirstBaselineRun -and $isFirstBaseline) {
|
||||
Write-ExchLog 'Forwarding: first baseline - alerts suppressed (SuppressAlertsOnFirstBaselineRun)'
|
||||
$firstBaselineSeeded = $true
|
||||
$toAlert = @()
|
||||
}
|
||||
foreach ($f in $toAlert) {
|
||||
$body = Format-ForwardingFindingMessage -Finding $f
|
||||
Send-MonitorNotification -Message $body -EmailSubject 'Exchange: external forward' | Out-Null
|
||||
}
|
||||
|
||||
if ($findings.Count -eq 0 -and $NotifyWhenForwardingScanClean) {
|
||||
$summary = "<b>Exchange: forwarding scan</b>`r`nNo external forward (Inbox / mailbox / transport).`r`nHost: $(ConvertTo-TelegramHtml $env:COMPUTERNAME)"
|
||||
Send-MonitorNotification -Message $summary -EmailSubject 'Exchange: forward scan OK' | Out-Null
|
||||
} elseif ($newFindings.Count -eq 0 -and $AlertOnlyOnNewForwardingFindings) {
|
||||
Write-ExchLog 'Forwarding: no changes (known findings in baseline only)'
|
||||
}
|
||||
|
||||
Save-ForwardingBaseline -FindingIds $allIds
|
||||
Write-TextFileUtf8Bom -Path $InboxHeartbeatFile -Text (Get-Date -Format 'yyyy-MM-dd HH:mm:ss')
|
||||
Send-ExchangeInboxScanSummary -TotalFindings $findings.Count -NewFindings $newFindings.Count `
|
||||
-MailboxCount $mailboxCount -FirstBaselineSeeded $firstBaselineSeeded -ScopeLabel $scopeLabel
|
||||
Write-ExchLog 'Inbox/Forwarding scan finished'
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# WATCHDOG
|
||||
# ============================================
|
||||
|
||||
function Get-HeartbeatAgeSeconds {
|
||||
param([string]$Path)
|
||||
if (-not (Test-Path -LiteralPath $Path)) { return [double]::PositiveInfinity }
|
||||
try {
|
||||
$t = Get-Content -LiteralPath $Path -TotalCount 1 -ErrorAction Stop
|
||||
if ([string]::IsNullOrWhiteSpace($t)) { return [double]::PositiveInfinity }
|
||||
$dt = [datetime]::ParseExact($t.Trim(), 'yyyy-MM-dd HH:mm:ss', $null)
|
||||
return ((Get-Date) - $dt).TotalSeconds
|
||||
} catch { return [double]::PositiveInfinity }
|
||||
}
|
||||
|
||||
function Invoke-ExchangeWatchdog {
|
||||
Write-WdLog "Watchdog Exchange-MailSecurity v$ScriptVersion"
|
||||
$issues = [System.Collections.Generic.List[string]]::new()
|
||||
|
||||
$qAge = Get-HeartbeatAgeSeconds -Path $QueuesHeartbeatFile
|
||||
if ($qAge -gt $QueuesHeartbeatStaleSeconds) {
|
||||
$issues.Add("Queues: no successful scan > $([int]$qAge) s (threshold $QueuesHeartbeatStaleSeconds s)") | Out-Null
|
||||
}
|
||||
|
||||
$iAge = Get-HeartbeatAgeSeconds -Path $InboxHeartbeatFile
|
||||
if ($iAge -gt $InboxHeartbeatStaleSeconds) {
|
||||
$issues.Add("Inbox/Forwarding: no successful scan > $([int]$iAge) s (threshold $InboxHeartbeatStaleSeconds s)") | Out-Null
|
||||
}
|
||||
|
||||
if ($issues.Count -eq 0) {
|
||||
Write-WdLog 'Watchdog: heartbeat OK'
|
||||
exit 0
|
||||
}
|
||||
|
||||
$msg = "<b>Exchange Mail Security: watchdog</b>`r`n"
|
||||
$msg += "Host: $(ConvertTo-TelegramHtml $env:COMPUTERNAME)`r`n"
|
||||
foreach ($iss in $issues) {
|
||||
$msg += "- $(ConvertTo-TelegramHtml $iss)`r`n"
|
||||
}
|
||||
|
||||
Send-MonitorNotification -Message $msg -EmailSubject 'Exchange monitor: watchdog' | Out-Null
|
||||
Write-WdLog "Watchdog: alert sent ($($issues.Count) issue(s))"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# MAIN
|
||||
# ============================================
|
||||
|
||||
if (-not (Test-RunningElevated)) {
|
||||
Write-ExchLog 'WARNING: not running elevated - EMS/tasks may fail.'
|
||||
}
|
||||
|
||||
Write-ExchLog "=== Exchange-MailSecurity v$ScriptVersion Mode=$Mode$(if ($WhatIf) { ' WhatIf' }) ==="
|
||||
Write-ExchangeScanSafetyWarnings
|
||||
|
||||
try {
|
||||
switch ($Mode) {
|
||||
'Queues' { Invoke-ExchangeQueueScan }
|
||||
'Inbox' { Invoke-ExchangeInboxAndForwardingScan }
|
||||
'Watchdog' { Invoke-ExchangeWatchdog }
|
||||
default { throw "Unknown Mode: $Mode" }
|
||||
}
|
||||
} catch {
|
||||
Write-ExchLog "ERROR: $($_.Exception.Message)"
|
||||
if ($_.ScriptStackTrace) { Write-ExchLog $_.ScriptStackTrace }
|
||||
exit 1
|
||||
}
|
||||
|
||||
exit 0
|
||||
@@ -1,8 +1,8 @@
|
||||
#Requires -RunAsAdministrator
|
||||
#Requires -RunAsAdministrator
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[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,
|
||||
[switch]$RunNow
|
||||
)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Register scheduled tasks for domain monitors (Exchange; AD later).
|
||||
.PARAMETER Target
|
||||
Exchange - Exchange-MailSecurity.ps1; Ad - reserved.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidateSet('Exchange', 'Ad')]
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Target
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$InstallRoot = [System.IO.Path]::GetFullPath("$env:ProgramData\RDP-login-monitor")
|
||||
$PsExe = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe"
|
||||
|
||||
function Test-RunningElevated {
|
||||
$id = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
if ($null -ne $id.User -and $id.User.Value -eq 'S-1-5-18') { return $true }
|
||||
$p = New-Object Security.Principal.WindowsPrincipal($id)
|
||||
return $p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
}
|
||||
|
||||
if (-not (Test-RunningElevated)) {
|
||||
throw 'Run Install-DomainMonitors.ps1 as Administrator.'
|
||||
}
|
||||
|
||||
switch ($Target) {
|
||||
'Exchange' {
|
||||
$scriptPath = Join-Path $InstallRoot 'Exchange-MailSecurity.ps1'
|
||||
if (-not (Test-Path -LiteralPath $scriptPath)) {
|
||||
throw "Not found: $scriptPath"
|
||||
}
|
||||
$notifyPath = Join-Path $InstallRoot 'Notify-Common.ps1'
|
||||
if (-not (Test-Path -LiteralPath $notifyPath)) {
|
||||
throw "Not found: $notifyPath"
|
||||
}
|
||||
& $PsExe -NoProfile -ExecutionPolicy Bypass -File $scriptPath -InstallTasks
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
Write-Host 'Exchange: scheduled tasks registered.'
|
||||
}
|
||||
'Ad' {
|
||||
Write-Host 'AD-SecurityMonitor.ps1 is not implemented yet. Use -Target Exchange.'
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
exit 0
|
||||
@@ -1,86 +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
|
||||
|
||||
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
|
||||
+3884
-288
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,214 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Общие функции уведомлений (Telegram / SMTP) для скриптов RDP-login-monitor.
|
||||
.DESCRIPTION
|
||||
Dot-source после определения Write-NotifyLog в вызывающем скрипте.
|
||||
Ожидает переменные: $NotifyOrder, $TelegramBotToken, $TelegramChatID,
|
||||
$MailSmtpHost, $MailFrom, $MailTo и др. (см. Login_Monitor.ps1).
|
||||
#>
|
||||
|
||||
if (-not (Get-Command Write-NotifyLog -ErrorAction SilentlyContinue)) {
|
||||
function Write-NotifyLog {
|
||||
param([string]$Message)
|
||||
Write-Host $Message
|
||||
}
|
||||
}
|
||||
|
||||
function Unprotect-RdpMonitorDpapiB64 {
|
||||
param([Parameter(Mandatory = $true)][string]$Base64)
|
||||
Add-Type -AssemblyName System.Security
|
||||
$bytes = [Convert]::FromBase64String($Base64.Trim())
|
||||
$plain = [System.Security.Cryptography.ProtectedData]::Unprotect(
|
||||
$bytes,
|
||||
$null,
|
||||
[System.Security.Cryptography.DataProtectionScope]::LocalMachine
|
||||
)
|
||||
return [Text.Encoding]::UTF8.GetString($plain)
|
||||
}
|
||||
|
||||
function Initialize-NotifyCredentials {
|
||||
param(
|
||||
[string]$TelegramBotTokenProtectedB64 = '',
|
||||
[string]$TelegramChatIDProtectedB64 = '',
|
||||
[ref]$TelegramBotToken,
|
||||
[ref]$TelegramChatID,
|
||||
[string]$MailSmtpPasswordProtectedB64 = '',
|
||||
[ref]$MailSmtpPassword
|
||||
)
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($TelegramBotTokenProtectedB64)) {
|
||||
$TelegramBotToken.Value = Unprotect-RdpMonitorDpapiB64 -Base64 $TelegramBotTokenProtectedB64
|
||||
}
|
||||
if (-not [string]::IsNullOrWhiteSpace($TelegramChatIDProtectedB64)) {
|
||||
$TelegramChatID.Value = Unprotect-RdpMonitorDpapiB64 -Base64 $TelegramChatIDProtectedB64
|
||||
}
|
||||
if (-not [string]::IsNullOrWhiteSpace($MailSmtpPasswordProtectedB64)) {
|
||||
$MailSmtpPassword.Value = Unprotect-RdpMonitorDpapiB64 -Base64 $MailSmtpPasswordProtectedB64
|
||||
}
|
||||
}
|
||||
|
||||
function Test-NotifyTelegramConfigured {
|
||||
return (-not [string]::IsNullOrWhiteSpace($TelegramBotToken)) -and
|
||||
(-not [string]::IsNullOrWhiteSpace($TelegramChatID))
|
||||
}
|
||||
|
||||
function Test-NotifyEmailConfigured {
|
||||
return (-not [string]::IsNullOrWhiteSpace($MailSmtpHost)) -and
|
||||
(-not [string]::IsNullOrWhiteSpace($MailFrom)) -and
|
||||
(-not [string]::IsNullOrWhiteSpace($MailTo))
|
||||
}
|
||||
|
||||
function Get-NotifyOrderChannels {
|
||||
$configured = [System.Collections.Generic.List[string]]::new()
|
||||
if (Test-NotifyTelegramConfigured) { $configured.Add('telegram') | Out-Null }
|
||||
if (Test-NotifyEmailConfigured) { $configured.Add('email') | Out-Null }
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($NotifyOrder)) {
|
||||
return @($configured)
|
||||
}
|
||||
|
||||
$requested = [System.Collections.Generic.List[string]]::new()
|
||||
foreach ($part in ($NotifyOrder -split '[,\s;]+')) {
|
||||
$p = $part.Trim().ToLowerInvariant()
|
||||
if ([string]::IsNullOrWhiteSpace($p)) { continue }
|
||||
$channel = switch -Regex ($p) {
|
||||
'^(tg|telegram)$' { 'telegram' }
|
||||
'^(mail|email|e-mail)$' { 'email' }
|
||||
default {
|
||||
Write-NotifyLog "NotifyOrder: unknown channel '$part'"
|
||||
$null
|
||||
}
|
||||
}
|
||||
if ($null -eq $channel) { continue }
|
||||
if ($configured.Contains($channel) -and -not $requested.Contains($channel)) {
|
||||
$requested.Add($channel) | Out-Null
|
||||
}
|
||||
}
|
||||
return @($requested)
|
||||
}
|
||||
|
||||
function Get-NotifyChainHuman {
|
||||
$channels = @(Get-NotifyOrderChannels)
|
||||
if ($channels.Count -eq 0) { return 'none (Telegram and SMTP not configured)' }
|
||||
$labels = foreach ($ch in $channels) {
|
||||
switch ($ch) {
|
||||
'telegram' { 'Telegram' }
|
||||
'email' { 'Email (SMTP)' }
|
||||
default { $ch }
|
||||
}
|
||||
}
|
||||
return ($labels -join ' → ')
|
||||
}
|
||||
|
||||
function ConvertTo-TelegramHtml {
|
||||
param([string]$Text)
|
||||
if ($null -eq $Text) { return '' }
|
||||
return [System.Net.WebUtility]::HtmlEncode([string]$Text)
|
||||
}
|
||||
|
||||
function Send-TelegramMessage {
|
||||
param([string]$Message)
|
||||
|
||||
if (-not (Test-NotifyTelegramConfigured)) {
|
||||
Write-NotifyLog 'Telegram: token or chat_id missing'
|
||||
return $false
|
||||
}
|
||||
|
||||
$uri = "https://api.telegram.org/bot$TelegramBotToken/sendMessage"
|
||||
$body = @{
|
||||
chat_id = $TelegramChatID
|
||||
text = $Message
|
||||
parse_mode = "HTML"
|
||||
}
|
||||
|
||||
try {
|
||||
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
|
||||
$null = Invoke-RestMethod -Uri $uri -Method Post -Body $body -ErrorAction Stop -TimeoutSec 30
|
||||
return $true
|
||||
} catch {
|
||||
Write-NotifyLog "Telegram send error: $($_.Exception.Message)"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function ConvertTo-EmailHtmlBody {
|
||||
param([string]$TelegramHtmlMessage)
|
||||
$inner = [string]$TelegramHtmlMessage
|
||||
if ([string]::IsNullOrEmpty($inner)) { $inner = '' }
|
||||
$inner = $inner -replace "`r`n", "<br>`r`n"
|
||||
return @"
|
||||
<html>
|
||||
<body style="font-family:Segoe UI,Arial,sans-serif;font-size:14px;line-height:1.4;">
|
||||
$inner
|
||||
</body>
|
||||
</html>
|
||||
"@
|
||||
}
|
||||
|
||||
function Send-EmailNotification {
|
||||
param(
|
||||
[string]$Message,
|
||||
[string]$Subject = "RDP Login Monitor"
|
||||
)
|
||||
|
||||
if (-not (Test-NotifyEmailConfigured)) {
|
||||
Write-NotifyLog 'Email: SMTP not configured'
|
||||
return $false
|
||||
}
|
||||
|
||||
try {
|
||||
$toList = @($MailTo -split '[,;]' | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
|
||||
if ($toList.Count -eq 0) { return $false }
|
||||
|
||||
$mailParams = @{
|
||||
To = $toList
|
||||
From = $MailFrom.Trim()
|
||||
Subject = $Subject
|
||||
Body = (ConvertTo-EmailHtmlBody -TelegramHtmlMessage $Message)
|
||||
BodyAsHtml = $true
|
||||
SmtpServer = $MailSmtpHost.Trim()
|
||||
Port = [int]$MailSmtpPort
|
||||
Encoding = [System.Text.Encoding]::UTF8
|
||||
ErrorAction = 'Stop'
|
||||
}
|
||||
if ($MailSmtpSsl -or $MailSmtpStartTls) { $mailParams['UseSsl'] = $true }
|
||||
if (-not [string]::IsNullOrWhiteSpace($MailSmtpUser)) {
|
||||
$securePass = if ([string]::IsNullOrWhiteSpace($MailSmtpPassword)) {
|
||||
New-Object System.Security.SecureString
|
||||
} else {
|
||||
ConvertTo-SecureString $MailSmtpPassword -AsPlainText -Force
|
||||
}
|
||||
$mailParams['Credential'] = New-Object System.Management.Automation.PSCredential($MailSmtpUser.Trim(), $securePass)
|
||||
}
|
||||
|
||||
Send-MailMessage @mailParams
|
||||
return $true
|
||||
} catch {
|
||||
Write-NotifyLog "Email send error: $($_.Exception.Message)"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Send-MonitorNotification {
|
||||
param(
|
||||
[string]$Message,
|
||||
[string]$EmailSubject = "RDP Login Monitor"
|
||||
)
|
||||
|
||||
$channels = @(Get-NotifyOrderChannels)
|
||||
if ($channels.Count -eq 0) {
|
||||
Write-NotifyLog 'Notification skipped: no channels configured'
|
||||
return $false
|
||||
}
|
||||
|
||||
$anyOk = $false
|
||||
foreach ($ch in $channels) {
|
||||
$ok = switch ($ch) {
|
||||
'telegram' { Send-TelegramMessage -Message $Message }
|
||||
'email' { Send-EmailNotification -Message $Message -Subject $EmailSubject }
|
||||
default { $false }
|
||||
}
|
||||
if ($ok) { $anyOk = $true }
|
||||
}
|
||||
return $anyOk
|
||||
}
|
||||
@@ -1,130 +1,62 @@
|
||||
# RDP Login Monitor
|
||||
|
||||
PowerShell-набор для мониторинга входов в Windows с отправкой уведомлений в Telegram.
|
||||
**Версия:** `2.1.14-SAC` (`$ScriptVersion` + `version.txt`)
|
||||
|
||||
## Актуальная схема (рекомендуется)
|
||||
PowerShell-мониторинг Windows: RDP/RDS, RD Gateway, WinRM, admin share, блокировки УЗ, heartbeat, отчёты.
|
||||
|
||||
- Базовый путь установки: **`C:\ProgramData\RDP-login-monitor\`**.
|
||||
- Основной скрипт: **`Login_Monitor.ps1`** — журнал Security **`4624`/`4625`** (логика зависит от типа ОС: рабочая станция или сервер/КД), при наличии журнала — **Remote Connection Manager `1149`** (часто актуально для РС с RDP), при роли **RD Gateway** — **`302`/`303`**, **ежедневный отчёт** в Telegram (активные сессии через `quser`), **heartbeat**, **ротация логов**, уведомления в Telegram.
|
||||
- Установка задач: запуск **`Login_Monitor.ps1 -InstallTasks`** создаёт:
|
||||
- `RDP-Login-Monitor` (основной монитор),
|
||||
- `RDP-Login-Monitor-Watchdog` (контроль процесса каждые 5 минут).
|
||||
- Доменная доставка и обновления: **`Deploy-LoginMonitor.ps1`** + **`version.txt`** с шары `NETLOGON`. После успешного деплоя в приветственном сообщении Telegram может появиться отметка об обновлении (файл **`deploy_last_update.txt`** рядом с логами).
|
||||
- Для полной инструкции по деплою/GPO используйте **[DEPLOY.md](DEPLOY.md)**.
|
||||
- **`Encrypt-DpapiForRdpMonitor.ps1`** — опционально для подготовки DPAPI-строк токена/chat id.
|
||||
|
||||
## Что изменилось (важное)
|
||||
|
||||
- **Кодировка `.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` запускается через `cmd.exe` с перехватом `stdout+stderr`, чтобы не ломать выполнение при `$ErrorActionPreference = "Stop"`.
|
||||
|
||||
## 1) Подготовка
|
||||
|
||||
1. Подготовьте папку установки:
|
||||
- `C:\ProgramData\RDP-login-monitor\`
|
||||
2. Скопируйте в неё как минимум:
|
||||
- `Login_Monitor.ps1`
|
||||
- (для доменного развёртывания отдельно на шаре) `Deploy-LoginMonitor.ps1` и `version.txt`.
|
||||
3. Откройте `Login_Monitor.ps1` и задайте токен/чат:
|
||||
- `$TelegramBotToken` или `...ProtectedB64`
|
||||
- `$TelegramChatID` или `...ProtectedB64`
|
||||
4. Запускайте с правами администратора (чтение `Security` журнала и регистрация задач).
|
||||
5. Логи и служебные файлы будут в:
|
||||
- `C:\ProgramData\RDP-login-monitor\Logs\`
|
||||
6. (Опционально) Подавление части алертов по списку — см. раздел **«7) ignore.lst»** ниже.
|
||||
|
||||
## 2) Ручной запуск
|
||||
|
||||
Используйте этот вариант для быстрой проверки старта/логики без установки задач планировщика.
|
||||
## Установка (домен)
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\RDP-login-monitor\Login_Monitor.ps1"
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "\\<DC>\NETLOGON\RDP-login-monitor\Deploy-LoginMonitor.ps1"
|
||||
```
|
||||
|
||||
Примечание: при ручном запуске монитор работает в текущей сессии до остановки (например, `Ctrl+C`).
|
||||
Каталог: `C:\ProgramData\RDP-login-monitor\` · настройки: `login_monitor.settings.ps1` (не перезаписываются при деплое).
|
||||
|
||||
## 3) Запуск через Планировщик заданий (Task Scheduler)
|
||||
## Обновление через SAC (WinRM)
|
||||
|
||||
Текущая схема: вручную задачи в GUI создавать не нужно.
|
||||
С карточки хоста в SAC: **«Обновить через WinRM»** (SAC ≥ 0.20.15). Сервер тянет репозиторий с git, отдаёт zip по HTTPS; на ПК **не нужны** git и NETLOGON.
|
||||
|
||||
Достаточно запустить:
|
||||
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` | Секреты и параметры (локально) |
|
||||
|
||||
## Быстрые проверки
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\RDP-login-monitor\Login_Monitor.ps1" -InstallTasks
|
||||
Get-Content "C:\ProgramData\RDP-login-monitor\Logs\login_monitor.log" -Tail 60
|
||||
Login_Monitor.ps1 -CheckSac
|
||||
```
|
||||
|
||||
Скрипт сам зарегистрирует `RDP-Login-Monitor` и `RDP-Login-Monitor-Watchdog`, а также запросит немедленный первый запуск задач.
|
||||
## Документация
|
||||
|
||||
Для доменной установки/обновления с шары вручную ничего в планировщике на клиенте настраивать не требуется: используйте `Deploy-LoginMonitor.ps1` (подробно в `DEPLOY.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`) в Telegram уходит сводка по **`quser`**; метка последнего отчёта — `Logs\last_daily_report.txt`.
|
||||
- Telegram при старте: при установленном **RD Session Host** (или аналогичных компонентах RDS, не только шлюз) — строка про входы по RDP/RDS на этом сервере; при доступном журнале **RD Gateway** — отдельная строка про подключения к **внутренним целевым ПК** через шлюз (302/303). Узел только с ролью RD Gateway не дублирует формулировку «хост сессий».
|
||||
|
||||
## 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).
|
||||
|
||||
## 7) Подавление уведомлений Security: `ignore.lst`
|
||||
|
||||
В каталоге установки можно положить файл **`C:\ProgramData\RDP-login-monitor\ignore.lst`** (рядом с **`Login_Monitor.ps1`**). Правила из списка проверяются **только** для Telegram-уведомлений по событиям **`4624`/`4625`** журнала Security (успех/неудача входа). Жёстко заданные в скрипте исключения (`ExcludedUsers`, локальный IP, сервисные учётные записи и т.д.) по-прежнему действуют для всех типов событий; **`ignore.lst`** добавляет к ним **дополнительные** совпадения именно для **4624/4625**.
|
||||
|
||||
События **RD Gateway (`302`/`303`)**, **RCM `1149`**, ежедневный отчёт и heartbeat **этим файлом не настраиваются** (для `1149` список не используется, даже если формально вызывается общая функция фильтрации).
|
||||
|
||||
### Как читается файл
|
||||
|
||||
- Чтение выполняется по мере обработки событий; содержимое **кэшируется в памяти**. Если **`LastWriteTimeUtc`** файла изменился (редактирование и сохранение), список **перечитывается автоматически** — перезапуск монитора не обязателен.
|
||||
- Кодировка: **UTF-8** (`Get-Content -Encoding UTF8`). Строка может начинаться с BOM — он отбрасывается при разборе.
|
||||
- Пустые строки пропускаются. Строки, начинающиеся с **`#`** или **`;`**, считаются комментариями.
|
||||
- Строка с **`:`**: берётся **первая** двоеточие — всё слева (после обрезки пробелов) определяет тип правила, всё справа — значение. Если справа пусто, строка игнорируется.
|
||||
- Строка **без** **`:`**: целиком трактуется как правило типа «любое совпадение» (см. ниже).
|
||||
|
||||
### Типы правил (левая часть до первого `:`)
|
||||
|
||||
| Левая часть (фрагменты совпадают как 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` вручную или через вашу систему конфигурации.
|
||||
|
||||
## Ключевые слова (для поиска репозитория)
|
||||
|
||||
`rdp`, `rd-gateway`, `rdp-gateway`, `rds`, `remote-desktop`, `windows-security-log`, `eventlog`, `event-id-4624`, `event-id-4625`, `event-id-302`, `event-id-303`, `powershell`, `telegram-bot`, `watchdog`, `gpo`, `netlogon`, `domain-deployment`, `windows-server`, `monitoring`
|
||||
- [Docs/README.md](Docs/README.md) — развёртывание, Exchange
|
||||
- [security-alert-center](https://git.kalinamall.ru/PapaTramp/security-alert-center) — сервер, RDG flap, qwinsta через SAC UI/Seaca
|
||||
|
||||
## English
|
||||
|
||||
See repository docs; agent contract is in SAC `docs/agent-integration.md`.
|
||||
|
||||
+42
-20
@@ -1,24 +1,29 @@
|
||||
# RDP Login Monitor
|
||||
|
||||
PowerShell toolkit for monitoring Windows logons with Telegram notifications.
|
||||
**Version:** `2.1.14-SAC` (`$ScriptVersion` + `version.txt`)
|
||||
|
||||
PowerShell toolkit for monitoring Windows logons with Telegram and/or Email (SMTP) notifications.
|
||||
|
||||
## Recommended layout
|
||||
|
||||
- Installation root: **`C:\ProgramData\RDP-login-monitor\`**.
|
||||
- Main script: **`Login_Monitor.ps1`** — Security log **`4624`/`4625`** (behavior depends on OS type: workstation vs server/domain controller), optional **Remote Connection Manager `1149`** when the log is available (often useful for RDP-enabled workstations), **RD Gateway** events **`302`/`303`** when the gateway role/log is present, **daily report** to Telegram (active sessions via `quser`), **heartbeat**, **log rotation**, Telegram alerts.
|
||||
- Main script: **`Login_Monitor.ps1`** — Security log **`4624`/`4625`** (behavior depends on OS type: workstation vs server/domain controller), **aggregated `4625` burst alerts** (two tiers: IP+user and IP-only), optional **Remote Connection Manager `1149`** when the log is available (often useful for RDP-enabled workstations), **RD Gateway** events **`302`/`303`** when the gateway role/log is present, on the **DC where the monitor runs** (hostname matches **`$LockoutMonitorDomainController`**) — **`4740`** (account lockout + IPs from IIS ActiveSync), **daily report** (active sessions via `quser`), **heartbeat**, **log rotation**, alerts via Telegram and/or Email.
|
||||
- Scheduled tasks: run **`Login_Monitor.ps1 -InstallTasks`** to register:
|
||||
- `RDP-Login-Monitor` (main monitor),
|
||||
- `RDP-Login-Monitor-Watchdog` (process health check every 5 minutes).
|
||||
- Domain delivery and upgrades: **`Deploy-LoginMonitor.ps1`** + **`version.txt`** on a share such as `NETLOGON`. After a successful deploy, the startup Telegram message may include an update note (file **`deploy_last_update.txt`** next to logs).
|
||||
- Full deploy/GPO guidance: **[DEPLOY.md](DEPLOY.md)**.
|
||||
- **`Encrypt-DpapiForRdpMonitor.ps1`** — optional helper to prepare DPAPI-protected Base64 for the bot token / chat id.
|
||||
- Domain delivery and upgrades: **`Deploy-LoginMonitor.ps1`** + **`version.txt`** on a share such as `NETLOGON`. After a successful deploy, the startup notification may include an update note (file **`deploy_last_update.txt`** next to logs).
|
||||
- Deployment docs: **[Docs/README.md](Docs/README.md)** (RDP monitor, Exchange, NETLOGON).
|
||||
- **`Encrypt-DpapiForRdpMonitor.ps1`** — optional helper to prepare DPAPI-protected Base64 for the bot token / chat id and SMTP password (`$MailSmtpPasswordProtectedB64` in the settings file).
|
||||
- **RDP monitor local settings:** **`login_monitor.settings.ps1`** in the install directory (template **`login_monitor.settings.example.ps1`**). When **`Login_Monitor.ps1`** is auto-updated from the share, the settings file is **not overwritten** (same pattern as **`exchange_monitor.settings.ps1`** for Exchange).
|
||||
|
||||
## Notable behavior
|
||||
|
||||
- **`.ps1` encoding**: `.editorconfig` and `.gitattributes` encourage **`*.ps1`** as **UTF-8 with BOM** and **CRLF**, reducing mojibake and PowerShell parse issues.
|
||||
- **Log encoding**: `login_monitor.log` / `watchdog.log` are written as **UTF-8 with BOM** (BOM is applied to existing files if missing) so viewers like **FAR Manager** do not mis-detect encoding.
|
||||
- **`auditpol` on Russian Windows**: auditing checks use the **`Вход/выход`** category and **`Вход в систему` / `Выход из системы`** subcategories (expect **`Успех и сбой`**), avoiding errors such as `0x00000057` when English names like `Logon` are absent on a localized OS.
|
||||
- **Stability**: `auditpol` is invoked via `cmd.exe` with merged stdout/stderr so `$ErrorActionPreference = 'Stop'` does not abort on stderr-only output.
|
||||
- **`auditpol` on Russian Windows**: auditing checks use the **`Вход/выход`** category and **`Вход в систему` / `Выход из системы`** subcategories (expect **`Успех и сбой`** or **`Успех и отказ`**, depending on OS build), avoiding errors such as `0x00000057` when English names like `Logon` are absent on a localized OS.
|
||||
- **Stability**: `auditpol` is invoked via full path `%SystemRoot%\System32\auditpol.exe` (no PATH dependency); stdout and stderr are merged via `ProcessStartInfo`.
|
||||
- **`4625` burst alerts**: when `$FailedLogonRateLimitEnabled` is true — tier 1: **5** failures in **60** s per **IP+user**; tier 2: **12** in **60** s per **IP** (spray). Below thresholds, individual `4625` alerts are sent; during a burst, aggregated alerts replace per-event noise. No automatic IP blocking. Tune at the top of `Login_Monitor.ps1`.
|
||||
- **Exchange Mail Security** (`Exchange-MailSecurity.ps1`): Exchange server only — queues, external forwarding, watchdog. See **[Docs/exchange-mail-security.md](Docs/exchange-mail-security.md)**.
|
||||
|
||||
## 1) Preparation
|
||||
|
||||
@@ -26,14 +31,18 @@ PowerShell toolkit for monitoring Windows logons with Telegram notifications.
|
||||
- `C:\ProgramData\RDP-login-monitor\`
|
||||
2. Copy at least:
|
||||
- `Login_Monitor.ps1`
|
||||
- (for domain rollout on a share) `Deploy-LoginMonitor.ps1` and `version.txt`.
|
||||
3. Edit `Login_Monitor.ps1` and set the bot token / chat:
|
||||
- `$TelegramBotToken` or `$TelegramBotTokenProtectedB64`
|
||||
- `$TelegramChatID` or `$TelegramChatIDProtectedB64`
|
||||
- `login_monitor.settings.example.ps1` → rename to **`login_monitor.settings.ps1`** and configure (see step 3)
|
||||
- (for domain rollout on a share) `Deploy-LoginMonitor.ps1`, `version.txt`, and `login_monitor.settings.example.ps1`
|
||||
3. Configure **`C:\ProgramData\RDP-login-monitor\login_monitor.settings.ps1`** (do not put secrets in `Login_Monitor.ps1` — they are overwritten on deploy):
|
||||
- **Telegram:** `$TelegramBotToken` / `$TelegramChatID` or `...ProtectedB64`
|
||||
- **Email (SMTP):** `$MailSmtpHost`, `$MailFrom`, `$MailTo`, `$MailSmtpPort` (default 587), optionally `$MailSmtpUser` / `$MailSmtpPassword` (or `$MailSmtpPasswordProtectedB64` via DPAPI), `$MailSmtpStartTls` / `$MailSmtpSsl`
|
||||
- **Order:** `$NotifyOrder` — empty = auto (Telegram → Email, configured channels only); otherwise `telegram,email`, `email`, etc. (`tg`, `mail` are accepted)
|
||||
4. Run elevated (Security log access and task registration).
|
||||
5. Logs and auxiliary files:
|
||||
- `C:\ProgramData\RDP-login-monitor\Logs\`
|
||||
6. (Optional) Suppress some Security alerts via **`ignore.lst`** — see **section 7** below.
|
||||
7. (Optional) AD account lockout monitoring on a DC — in **`login_monitor.settings.ps1`**: **`$LockoutMonitorDomainController`** (short hostname of the machine **where the monitor is installed and running**), **`$NetBiosDomainName`**, **`$ExchangeIisLogPath`**, **`$ExchangeIisLogMinutesBeforeLockout`** (default 30), **`$ExchangeIisLogTailLines`** (default 5000), **`$ExchangeServerHostForIisExclude`**. Alerts include the user from event **4740** and client IPs from IIS within the time window before lockout. In **`ignore.lst`** use prefix **`4740:`** or **`all:`** — see **`ignore.lst.example`**.
|
||||
8. Heartbeat: if **`Logs\last_heartbeat.txt`** is not updated for longer than **`$HeartbeatStaleAlertMultiplier` × `$HeartbeatInterval`** (default 2×1 h) — alert via Telegram/Email.
|
||||
|
||||
## 2) Manual run
|
||||
|
||||
@@ -57,7 +66,7 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\RDP-logi
|
||||
|
||||
The script registers `RDP-Login-Monitor` and `RDP-Login-Monitor-Watchdog`, then triggers an immediate first run.
|
||||
|
||||
For domain deployment from a share you do not configure the scheduler on clients by hand — use `Deploy-LoginMonitor.ps1` (see `DEPLOY.md`).
|
||||
For domain deployment from a share you do not configure the scheduler on clients by hand — use `Deploy-LoginMonitor.ps1` (see [Docs/deploy-rdp-login-monitor.md](Docs/deploy-rdp-login-monitor.md)).
|
||||
|
||||
## 4) Post-install checks
|
||||
|
||||
@@ -66,8 +75,10 @@ For domain deployment from a share you do not configure the scheduler on clients
|
||||
- `C:\ProgramData\RDP-login-monitor\Logs\watchdog.log`
|
||||
- Heartbeat:
|
||||
- `C:\ProgramData\RDP-login-monitor\Logs\last_heartbeat.txt` updates on **`$HeartbeatInterval`** (hourly by default).
|
||||
- Daily report: after the first daily window (default **09:00**, controlled by **`$DailyReportHour`** / **`$DailyReportMinute`** in `Login_Monitor.ps1`), Telegram receives a `quser` summary; last run marker: `Logs\last_daily_report.txt`.
|
||||
- Startup Telegram message: with **RD Session Host** (or broader RDS session components, not gateway-only) you get the RDS/RDP session-host line; when the **RD Gateway** log is available you get a separate line about connections to **internal targets** through the gateway (302/303). A gateway-only node does not duplicate the “session host” wording.
|
||||
- Daily report: after the first daily window (default **09:00**, controlled by **`$DailyReportHour`** / **`$DailyReportMinute`** in `Login_Monitor.ps1`), a `quser` summary is sent (Telegram/Email); last run marker: `Logs\last_daily_report.txt`.
|
||||
- Stale heartbeat: if **`last_heartbeat.txt`** is stale beyond **`$HeartbeatStaleAlertMultiplier` × `$HeartbeatInterval`** — alert via Telegram/Email (see preparation step 8).
|
||||
- At startup (Telegram/Email): **notification channels** line (actual delivery order) plus RDS/4740 mode per configuration.
|
||||
- Startup message (Telegram): with **RD Session Host** (or broader RDS session components, not gateway-only) you get the RDS/RDP session-host line; when the **RD Gateway** log is available you get a separate line about connections to **internal targets** through the gateway (302/303). A gateway-only node does not duplicate the “session host” wording.
|
||||
|
||||
## 5) Automatic restart on failure
|
||||
|
||||
@@ -81,14 +92,15 @@ 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).
|
||||
- **`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).
|
||||
- **`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.
|
||||
|
||||
## 7) Suppressing Security alerts: `ignore.lst`
|
||||
|
||||
Place a file **`C:\ProgramData\RDP-login-monitor\ignore.lst`** next to **`Login_Monitor.ps1`**. Rules in this list are evaluated **only** for Telegram notifications from Security events **`4624`/`4625`** (successful/failed logons). Built-in exclusions in the script (`ExcludedUsers`, loopback IPs, service-style accounts, etc.) still apply to all event paths; **`ignore.lst`** adds **extra** matches **for 4624/4625 only**.
|
||||
Place a file **`C:\ProgramData\RDP-login-monitor\ignore.lst`** next to **`Login_Monitor.ps1`**. By default rules apply to **`4624`/`4625`**; prefix **`4740:`** (or **`lockout:`**) — account lockouts only; **`all:`** — logons and **4740**. For **4740**, rule type **`ip:`** is matched against IPs from IIS ActiveSync. Built-in script exclusions still apply to all event types except **4740** (lockouts use **`ignore.lst`** and built-in user checks only).
|
||||
|
||||
**RD Gateway (`302`/`303`)**, **RCM `1149`**, the daily report, and heartbeat **are not controlled** by this file (for `1149` the list is not applied, even though a shared filter function runs).
|
||||
**RD Gateway (`302`/`303`)**, **RCM `1149`**, the daily report, and heartbeat **are not controlled** by this file.
|
||||
|
||||
### How the file is loaded
|
||||
|
||||
@@ -98,7 +110,17 @@ Place a file **`C:\ProgramData\RDP-login-monitor\ignore.lst`** next to **`Login_
|
||||
- If the line contains **`:`**, the **first** colon splits the line: the left part (trimmed) selects the rule kind, the right part is the value. If the right part is empty, the line is ignored.
|
||||
- If there is **no** colon, the whole trimmed line is one “match-any” value (see below).
|
||||
|
||||
### Rule kinds (left part before the first `:`)
|
||||
### Scope prefix (at the start of the line, before the rule type)
|
||||
|
||||
| Prefix | Events |
|
||||
| --- | --- |
|
||||
| *(none)* | **4624**, **4625** |
|
||||
| `4740:`, `lockout:` | **4740** |
|
||||
| `all:`, `*:` | **4624**, **4625**, **4740** |
|
||||
|
||||
Example: `4740:user:svc_sync` — do not alert on lockout for that account.
|
||||
|
||||
### Rule kinds (left part before the first `:` after the scope prefix)
|
||||
|
||||
| Left part (case-insensitive regex fragments) | Event field |
|
||||
| --- | --- |
|
||||
@@ -121,8 +143,8 @@ Explicit **User** / **Workstation** / **Ip** kinds only compare their respective
|
||||
### Examples and deployment
|
||||
|
||||
- See **`ignore.lst.example`** in the repo; copy it to the server as **`ignore.lst`** and edit.
|
||||
- **`Deploy-LoginMonitor.ps1`** does **not** copy this file — rules are usually host-specific; create **`ignore.lst`** manually or via your configuration tooling.
|
||||
- **`Deploy-LoginMonitor.ps1`** does **not** copy or overwrite **`ignore.lst`** or **`login_monitor.settings.ps1`**; if settings are missing, Deploy creates them once from **`login_monitor.settings.example.ps1`** on the share.
|
||||
|
||||
## Keywords (for discovery)
|
||||
|
||||
`rdp`, `rd-gateway`, `rdp-gateway`, `rds`, `remote-desktop`, `windows-security-log`, `eventlog`, `event-id-4624`, `event-id-4625`, `event-id-302`, `event-id-303`, `powershell`, `telegram-bot`, `watchdog`, `gpo`, `netlogon`, `domain-deployment`, `windows-server`, `monitoring`
|
||||
`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`
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Запрос задач планировщика RDP-login-monitor через schtasks /Query /XML (fallback для Get-ScheduledTask).
|
||||
#>
|
||||
|
||||
function Get-RdpMonitorSchtasksExe {
|
||||
return Join-Path $env:SystemRoot 'System32\schtasks.exe'
|
||||
}
|
||||
|
||||
function Get-RdpMonitorScheduledTaskXmlDocument {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$TaskName
|
||||
)
|
||||
|
||||
$exe = Get-RdpMonitorSchtasksExe
|
||||
$prevEa = $ErrorActionPreference
|
||||
try {
|
||||
$ErrorActionPreference = 'SilentlyContinue'
|
||||
$raw = & $exe /Query /TN $TaskName /XML 2>&1
|
||||
if ($LASTEXITCODE -ne 0) { return $null }
|
||||
$text = ($raw | Out-String).Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($text)) { return $null }
|
||||
if ($text -notmatch '(?s)<Task\b') { return $null }
|
||||
return [xml]$text
|
||||
} catch {
|
||||
return $null
|
||||
} finally {
|
||||
$ErrorActionPreference = $prevEa
|
||||
}
|
||||
}
|
||||
|
||||
function Test-RdpMonitorScheduledTaskExistsViaSchtasks {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$TaskName
|
||||
)
|
||||
return ($null -ne (Get-RdpMonitorScheduledTaskXmlDocument -TaskName $TaskName))
|
||||
}
|
||||
|
||||
function Convert-RdpMonitorScheduledTaskExecutionTimeLimitText {
|
||||
param([string]$LimitText)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($LimitText)) { return $null }
|
||||
$t = $LimitText.Trim()
|
||||
if ($t -eq 'PT0S') { return [TimeSpan]::Zero }
|
||||
try {
|
||||
return [System.Xml.XmlConvert]::ToTimeSpan($t)
|
||||
} catch {
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
function Get-RdpMonitorScheduledTaskExecutionTimeLimitFromDocument {
|
||||
param([xml]$Doc)
|
||||
|
||||
if ($null -eq $Doc) { return $null }
|
||||
|
||||
$ns = New-Object System.Xml.XmlNamespaceManager($Doc.NameTable)
|
||||
$ns.AddNamespace('t', 'http://schemas.microsoft.com/windows/2004/02/mit/task')
|
||||
$node = $Doc.SelectSingleNode('//t:Settings/t:ExecutionTimeLimit', $ns)
|
||||
if ($null -eq $node) {
|
||||
$node = $Doc.SelectSingleNode('//*[local-name()="Settings"]/*[local-name()="ExecutionTimeLimit"]')
|
||||
}
|
||||
if ($null -eq $node -or [string]::IsNullOrWhiteSpace($node.InnerText)) { return $null }
|
||||
return Convert-RdpMonitorScheduledTaskExecutionTimeLimitText -LimitText $node.InnerText.Trim()
|
||||
}
|
||||
|
||||
function Get-RdpMonitorScheduledTaskActionFromDocument {
|
||||
param([xml]$Doc)
|
||||
|
||||
if ($null -eq $Doc) { return $null }
|
||||
|
||||
$ns = New-Object System.Xml.XmlNamespaceManager($Doc.NameTable)
|
||||
$ns.AddNamespace('t', 'http://schemas.microsoft.com/windows/2004/02/mit/task')
|
||||
$cmdNode = $Doc.SelectSingleNode('//t:Actions/t:Exec/t:Command', $ns)
|
||||
$argNode = $Doc.SelectSingleNode('//t:Actions/t:Exec/t:Arguments', $ns)
|
||||
if ($null -eq $cmdNode) {
|
||||
$cmdNode = $Doc.SelectSingleNode('//*[local-name()="Actions"]/*[local-name()="Exec"]/*[local-name()="Command"]')
|
||||
}
|
||||
if ($null -eq $argNode) {
|
||||
$argNode = $Doc.SelectSingleNode('//*[local-name()="Actions"]/*[local-name()="Exec"]/*[local-name()="Arguments"]')
|
||||
}
|
||||
if ($null -eq $cmdNode) { return $null }
|
||||
|
||||
return [pscustomobject]@{
|
||||
Execute = [string]$cmdNode.InnerText
|
||||
Arguments = if ($null -ne $argNode) { [string]$argNode.InnerText } else { '' }
|
||||
}
|
||||
}
|
||||
|
||||
function Convert-RdpMonitorScheduledTaskExecutionTimeLimitValue {
|
||||
param($Limit)
|
||||
|
||||
if ($null -eq $Limit) { return $null }
|
||||
if ($Limit -is [TimeSpan]) { return $Limit }
|
||||
return Convert-RdpMonitorScheduledTaskExecutionTimeLimitText -LimitText ([string]$Limit)
|
||||
}
|
||||
|
||||
function Test-RdpMonitorScheduledTaskExecutionTimeLimitUnlimitedValue {
|
||||
param($Limit)
|
||||
|
||||
$normalized = Convert-RdpMonitorScheduledTaskExecutionTimeLimitValue -Limit $Limit
|
||||
if ($null -eq $normalized) { return $false }
|
||||
if ($normalized.Ticks -le 0) { return $true }
|
||||
if ($normalized.TotalDays -ge 999) { return $true }
|
||||
return $false
|
||||
}
|
||||
|
||||
function Get-RdpMonitorScheduledTaskExecutionTimeLimitResolved {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$TaskName
|
||||
)
|
||||
|
||||
try {
|
||||
$limit = (Get-ScheduledTask -TaskName $TaskName -ErrorAction Stop | Select-Object -First 1).Settings.ExecutionTimeLimit
|
||||
return [pscustomobject]@{
|
||||
Limit = $limit
|
||||
Source = 'Get-ScheduledTask'
|
||||
}
|
||||
} catch { }
|
||||
|
||||
$doc = Get-RdpMonitorScheduledTaskXmlDocument -TaskName $TaskName
|
||||
if ($null -eq $doc) {
|
||||
return [pscustomobject]@{
|
||||
Limit = $null
|
||||
Source = 'missing'
|
||||
}
|
||||
}
|
||||
|
||||
$limit = Get-RdpMonitorScheduledTaskExecutionTimeLimitFromDocument -Doc $doc
|
||||
return [pscustomobject]@{
|
||||
Limit = $limit
|
||||
Source = 'schtasks-xml'
|
||||
}
|
||||
}
|
||||
|
||||
function Test-RdpMonitorScheduledTaskExecutionTimeLimitUnlimited {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$TaskName
|
||||
)
|
||||
|
||||
$resolved = Get-RdpMonitorScheduledTaskExecutionTimeLimitResolved -TaskName $TaskName
|
||||
if ($resolved.Source -eq 'missing') { return $false }
|
||||
return (Test-RdpMonitorScheduledTaskExecutionTimeLimitUnlimitedValue -Limit $resolved.Limit)
|
||||
}
|
||||
|
||||
function Test-RdpMonitorScheduledTaskNeedsUnlimitedExecutionTimeLimit {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$TaskName
|
||||
)
|
||||
|
||||
$resolved = Get-RdpMonitorScheduledTaskExecutionTimeLimitResolved -TaskName $TaskName
|
||||
if ($resolved.Source -eq 'missing') { return $true }
|
||||
return (-not (Test-RdpMonitorScheduledTaskExecutionTimeLimitUnlimitedValue -Limit $resolved.Limit))
|
||||
}
|
||||
|
||||
function Get-RdpMonitorScheduledTaskExecutionTimeLimitLabel {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$TaskName
|
||||
)
|
||||
|
||||
$resolved = Get-RdpMonitorScheduledTaskExecutionTimeLimitResolved -TaskName $TaskName
|
||||
if ($resolved.Source -eq 'missing') { return '(task missing)' }
|
||||
|
||||
$limit = Convert-RdpMonitorScheduledTaskExecutionTimeLimitValue -Limit $resolved.Limit
|
||||
if ($null -eq $limit) { return '(null)' }
|
||||
if ($limit.Ticks -le 0) { return 'PT0S' }
|
||||
return $limit.ToString()
|
||||
}
|
||||
|
||||
function Test-RdpMonitorScheduledTaskActionMatchesViaSchtasks {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$TaskName,
|
||||
[Parameter(Mandatory = $true)][string]$ExpectedExe,
|
||||
[Parameter(Mandatory = $true)][string]$ExpectedArguments
|
||||
)
|
||||
|
||||
$doc = Get-RdpMonitorScheduledTaskXmlDocument -TaskName $TaskName
|
||||
if ($null -eq $doc) { return $false }
|
||||
|
||||
$action = Get-RdpMonitorScheduledTaskActionFromDocument -Doc $doc
|
||||
if ($null -eq $action) { return $false }
|
||||
if ($action.Execute.Trim() -ne $ExpectedExe.Trim()) { return $false }
|
||||
return ($action.Arguments.Trim() -eq $ExpectedArguments.Trim())
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Graceful restart RDP Login Monitor без Stop-Process.
|
||||
.DESCRIPTION
|
||||
settings — перечитать login_monitor.settings.ps1 в том же процессе PowerShell.
|
||||
recycle — корректно завершить монитор и запустить новый процесс (после обновления Login_Monitor.ps1).
|
||||
.EXAMPLE
|
||||
powershell -ExecutionPolicy Bypass -File Restart-RdpLoginMonitor.ps1
|
||||
powershell -ExecutionPolicy Bypass -File Restart-RdpLoginMonitor.ps1 -Recycle
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$Recycle
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$installRoot = [System.IO.Path]::GetFullPath("$env:ProgramData\RDP-login-monitor")
|
||||
$monitorScript = Join-Path $installRoot 'Login_Monitor.ps1'
|
||||
|
||||
if (-not (Test-Path -LiteralPath $monitorScript)) {
|
||||
Write-Error "Не найден: $monitorScript"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$args = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $monitorScript, '-RequestRestart')
|
||||
if ($Recycle) { $args += '-Recycle' }
|
||||
|
||||
& "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" @args
|
||||
exit $LASTEXITCODE
|
||||
+1270
File diff suppressed because it is too large
Load Diff
@@ -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 свежий."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Пример локальных настроек Exchange-MailSecurity.ps1
|
||||
.DESCRIPTION
|
||||
Скопируйте в C:\ProgramData\RDP-login-monitor\exchange_monitor.settings.ps1
|
||||
и задайте секреты / whitelist. Файл не деплоится с шары автоматически.
|
||||
#>
|
||||
|
||||
# Telegram (или DPAPI Base64 с Encrypt-DpapiForRdpMonitor.ps1)
|
||||
$TelegramBotToken = ''
|
||||
$TelegramChatID = ''
|
||||
# $TelegramBotTokenProtectedB64 = ''
|
||||
# $TelegramChatIDProtectedB64 = ''
|
||||
|
||||
# SMTP (опционально)
|
||||
# $MailSmtpHost = 'smtp.example.com'
|
||||
# $MailFrom = 'monitor@example.com'
|
||||
# $MailTo = 'admin@example.com'
|
||||
# $NotifyOrder = 'telegram,email'
|
||||
|
||||
# Внешние домены-партнёры (не считать угрозой)
|
||||
$ExternalDomainWhitelist = @(
|
||||
# 'partner-bank.ru'
|
||||
)
|
||||
|
||||
# Очереди
|
||||
$QueueMessageCountThreshold = 150
|
||||
|
||||
# --- Пилот VIP (рекомендуется для первого запуска) ---
|
||||
# $VipMailboxesOnly = $true
|
||||
# $VipMailboxes = @(
|
||||
# 'director@kalinamall.ru',
|
||||
# 'cfo@kalinamall.ru'
|
||||
# )
|
||||
# $VipMailboxPatterns = @(
|
||||
# '*@kalinamall.ru' # опционально: все ящики домена из Get-Mailbox
|
||||
# )
|
||||
|
||||
# Первый ночной скан: не слать сотни алертов по уже существующим пересылкам
|
||||
# $SuppressAlertsOnFirstBaselineRun = $true # по умолчанию в скрипте уже $true
|
||||
|
||||
# Отключённые Inbox rules с внешней пересылкой (важность «Средняя»)
|
||||
# $ScanDisabledInboxRulesWithExternalForward = $true
|
||||
|
||||
# Сводка в TG/Email после каждого скана
|
||||
# $SendInboxScanSummary = $true
|
||||
|
||||
# Удалённый EMS (если скрипт не на Exchange)
|
||||
# $ExchangeServerFqdn = 'fifth.kalinamall.ru'
|
||||
|
||||
# Не сканировать Inbox rules (битое хранилище правил / Watson на Get-InboxRule)
|
||||
# $SkipInboxScanMailboxes = @(
|
||||
# 'k.selezneva@kalinamall.ru'
|
||||
# )
|
||||
+34
-14
@@ -2,23 +2,43 @@
|
||||
# C:\ProgramData\RDP-login-monitor\ignore.lst
|
||||
#
|
||||
# Каждая непустая строка — одно правило. Строки с # или ; в начале — комментарии.
|
||||
# Подавляет только уведомления Security 4624/4625 (не RD Gateway, не RCM 1149).
|
||||
#
|
||||
# Форматы:
|
||||
# Область действия (префикс в начале строки, необязателен):
|
||||
# (по умолчанию) — только Security 4624/4625
|
||||
# 4740: — только блокировка учётной записи (4740); для IP — любой IP из IIS
|
||||
# shadow: — RDS Shadow Control (RCM 20506/20507/20510)
|
||||
# winrm: / pssession: — WinRM inbound / Enter-PSSession (Operational 91)
|
||||
# smb: / 5140: / adminshare: — доступ к админ-шару C$/ADMIN$ (Security 5140)
|
||||
# all: — 4624/4625, 4740, shadow, winrm, 5140
|
||||
#
|
||||
# Форматы правила (после префикса области):
|
||||
# user:domain\user
|
||||
# user:user
|
||||
# workstation:IVANOV
|
||||
# workstation:IVANOV (не для 4740)
|
||||
# ip:111.222.333.444
|
||||
# Можно вставить «как в Telegram» (берётся значение после первого «:»):
|
||||
# 👤 Пользователь: user
|
||||
# 🖥️ Рабочая станция (клиент из события): IVANOV
|
||||
# 🌐 IP адрес: 111.222.333.444
|
||||
#
|
||||
# Строка без префикса:
|
||||
# IVANOV — совпадение с именем рабочей станции ИЛИ с пользователем (sam) ИЛИ с IP (если строка — IPv4)
|
||||
# 111.222.333.444 — только IP (в реальной конфигурации укажите действительный IPv4 клиента)
|
||||
# domain\user — пользователь целиком
|
||||
# Строка без префикса типа:
|
||||
# IVANOV — рабочая станция / пользователь / IP (IPv4)
|
||||
# domain\user — пользователь
|
||||
|
||||
# user:domain\user
|
||||
# workstation:IVANOV
|
||||
# ip:111.222.333.444
|
||||
# --- только входы 4624/4625 ---
|
||||
# user:domain\service_account
|
||||
# ip:192.168.1.100
|
||||
|
||||
# --- только блокировки 4740 ---
|
||||
# 4740:user:test.user
|
||||
# 4740:ip:203.0.113.50
|
||||
|
||||
# --- только WinRM / Enter-PSSession inbound ---
|
||||
# winrm:user:DOMAIN\jump-admin
|
||||
# winrm:ip:192.168.160.50
|
||||
|
||||
# --- только admin share 5140 (C$, ADMIN$) ---
|
||||
# smb:user:DOMAIN\backup-svc
|
||||
# 5140:ip:192.168.160.50
|
||||
|
||||
# --- только RDS Shadow Control ---
|
||||
# shadow:user:DOMAIN\helpdesk
|
||||
|
||||
# --- все перечисленные события ---
|
||||
# all:user:domain\noise_account
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<#
|
||||
.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 = '8239219522:AAEyOZX3cwNfgGOMDkf-mgjTIuoaOh5gF7I'
|
||||
$TelegramChatID = '2843230'
|
||||
# $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 = 'UNMS Kalina'
|
||||
# --- Явный IPv4 хоста для SAC (опционально; иначе автоопределение) ---
|
||||
# $ServerIPv4 = '192.168.160.57'
|
||||
|
||||
# --- Security Alert Center (SAC) ---
|
||||
# off | exclusive | dual | fallback — см. security-alert-center/docs/agent-integration.md
|
||||
$UseSAC = 'fallback'
|
||||
$SacUrl = 'https://sac.kalinamall.ru'
|
||||
$SacApiKey = 'sac_UkOsAT3UWiQS54KK5OJPBDCSucysQDrKFju28wmYiz8'
|
||||
$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.160.57'
|
||||
)
|
||||
# --- 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 = 'K6A-DC3'
|
||||
$NetBiosDomainName = 'B26'
|
||||
$ExchangeIisLogPath = '\\fifth.kalinamall.ru\c$\inetpub\logs\LogFiles\W3SVC1'
|
||||
$ExchangeServerHostForIisExclude = ''
|
||||
$ExchangeIisLogTailLines = 5000
|
||||
$ExchangeIisLogMinutesBeforeLockout = 30
|
||||
@@ -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)"
|
||||
@@ -0,0 +1,46 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Smoke/autotests for RDP-login-monitor deploy and SAC paths.
|
||||
.EXAMPLE
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File tools\Run-RdpMonitorTests.ps1
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$testsDir = Join-Path $PSScriptRoot 'tests'
|
||||
$suites = @(
|
||||
'Test-ScriptSyntaxAll.ps1',
|
||||
'Test-TaskQueryModule.ps1',
|
||||
'Test-DeployTaskLimit.ps1',
|
||||
'Test-SecurityPollCursor.ps1',
|
||||
'Test-SendDeploySacNotice.ps1'
|
||||
)
|
||||
|
||||
Write-Host '=== RDP-login-monitor autotests ==='
|
||||
$failed = 0
|
||||
foreach ($suite in $suites) {
|
||||
$path = Join-Path $testsDir $suite
|
||||
if (-not (Test-Path -LiteralPath $path)) {
|
||||
Write-Host "FAIL: missing suite $path"
|
||||
$failed++
|
||||
continue
|
||||
}
|
||||
Write-Host "--- $suite ---"
|
||||
try {
|
||||
& $path
|
||||
} catch {
|
||||
Write-Host "SUITE FAILED: $suite - $($_.Exception.Message)"
|
||||
$failed++
|
||||
}
|
||||
}
|
||||
|
||||
if ($failed -gt 0) {
|
||||
Write-Host ('=== FAILED ({0} suite(s)) ===' -f $failed)
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host '=== ALL PASSED ==='
|
||||
exit 0
|
||||
@@ -0,0 +1,51 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Просмотр недавних 4624 с полями для диагностики RDP-login-monitor.
|
||||
.EXAMPLE
|
||||
.\Show-Rdp4624Recent.ps1
|
||||
.\Show-Rdp4624Recent.ps1 -Minutes 30 -User jdoe
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[int]$Minutes = 15,
|
||||
[string]$User = '',
|
||||
[int]$MaxEvents = 50
|
||||
)
|
||||
|
||||
$start = (Get-Date).AddMinutes(-$Minutes)
|
||||
Write-Host "Security 4624 since $($start.ToString('yyyy-MM-dd HH:mm:ss')) (local time)" -ForegroundColor Cyan
|
||||
|
||||
$events = @(Get-WinEvent -FilterHashtable @{
|
||||
LogName = 'Security'
|
||||
Id = 4624
|
||||
StartTime = $start
|
||||
} -MaxEvents $MaxEvents -ErrorAction SilentlyContinue)
|
||||
|
||||
if ($events.Count -eq 0) {
|
||||
Write-Host 'No 4624 events in window.'
|
||||
exit 0
|
||||
}
|
||||
|
||||
function Get-EvProp($Event, [string]$Name) {
|
||||
$xml = [xml]$Event.ToXml()
|
||||
$n = $xml.Event.EventData.Data | Where-Object { $_.Name -eq $Name } | Select-Object -First 1
|
||||
if ($null -eq $n) { return '-' }
|
||||
return [string]$n.'#text'
|
||||
}
|
||||
|
||||
$rows = foreach ($ev in $events) {
|
||||
$u = Get-EvProp $ev 'TargetUserName'
|
||||
if ($User -and $u -notlike "*$User*") { continue }
|
||||
[pscustomobject]@{
|
||||
TimeCreated = $ev.TimeCreated.ToString('yyyy-MM-dd HH:mm:ss')
|
||||
RecordId = $ev.RecordId
|
||||
User = $u
|
||||
LogonType = Get-EvProp $ev 'LogonType'
|
||||
IpAddress = Get-EvProp $ev 'IpAddress'
|
||||
Workstation = Get-EvProp $ev 'WorkstationName'
|
||||
Process = Get-EvProp $ev 'LogonProcessName'
|
||||
}
|
||||
}
|
||||
|
||||
$rows | Format-Table -AutoSize
|
||||
Write-Host "`nTip: monitor log — Select-String -Path 'C:\ProgramData\RDP-login-monitor\Logs\*.log' -Pattern 'Notify:|Skip 4624|Notify dedup'"
|
||||
@@ -0,0 +1,8 @@
|
||||
$errs = $null
|
||||
[void][System.Management.Automation.Language.Parser]::ParseFile(
|
||||
(Join-Path $PSScriptRoot '..\Deploy-LoginMonitor.ps1'),
|
||||
[ref]$null,
|
||||
[ref]$errs
|
||||
)
|
||||
if ($errs) { $errs | ForEach-Object { $_.ToString() }; exit 1 }
|
||||
Write-Output 'OK'
|
||||
@@ -0,0 +1,55 @@
|
||||
# Проверка парсинга UserData/EventInfo для RD Gateway 303 (BytesReceived != ErrorCode).
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$sample303 = @'
|
||||
<Event xmlns="http://schemas.microsoft.com/win/2004/08/events/event">
|
||||
<System>
|
||||
<Provider Name="Microsoft-Windows-TerminalServices-Gateway" />
|
||||
<EventID>303</EventID>
|
||||
<TimeCreated SystemTime="2026-06-02T23:51:21.033855700Z" />
|
||||
</System>
|
||||
<UserData>
|
||||
<EventInfo xmlns="aag">
|
||||
<Username>CONTOSO\TSA</Username>
|
||||
<IpAddress>95.154.72.73</IpAddress>
|
||||
<Resource>192.168.164.43</Resource>
|
||||
<BytesReceived>1991</BytesReceived>
|
||||
<BytesTransfered>2116</BytesTransfered>
|
||||
<SessionDuration>0</SessionDuration>
|
||||
<ConnectionProtocol>HTTP</ConnectionProtocol>
|
||||
<ErrorCode>1226</ErrorCode>
|
||||
</EventInfo>
|
||||
</UserData>
|
||||
</Event>
|
||||
'@
|
||||
|
||||
function Get-RDGatewayUserDataEventInfoMapFromXmlText {
|
||||
param([string]$XmlText)
|
||||
$map = @{}
|
||||
$xml = [xml]$XmlText
|
||||
$eventInfo = $xml.Event.UserData.EventInfo
|
||||
foreach ($node in @($eventInfo.ChildNodes)) {
|
||||
if ($null -eq $node -or $node.NodeType -ne [System.Xml.XmlNodeType]::Element) { continue }
|
||||
$map[$node.LocalName] = [string]$node.InnerText
|
||||
}
|
||||
return $map
|
||||
}
|
||||
|
||||
$map = Get-RDGatewayUserDataEventInfoMapFromXmlText -XmlText $sample303
|
||||
if ($map['ErrorCode'] -ne '1226') {
|
||||
throw "Expected ErrorCode=1226, got $($map['ErrorCode'])"
|
||||
}
|
||||
if ($map['BytesReceived'] -ne '1991') {
|
||||
throw "Expected BytesReceived=1991, got $($map['BytesReceived'])"
|
||||
}
|
||||
if ($map['SessionDuration'] -ne '0') {
|
||||
throw "Expected SessionDuration=0, got $($map['SessionDuration'])"
|
||||
}
|
||||
Write-Host 'OK: RD Gateway EventInfo XML fields parsed correctly (ErrorCode != BytesReceived).'
|
||||
|
||||
# 1226 в sample — типичный штатный код закрытия туннеля (в Login_Monitor.ps1 → disconnected, не failed).
|
||||
if ($map['ErrorCode'] -ne '1226') {
|
||||
throw 'Expected sample ErrorCode 1226 for standard RDG disconnect'
|
||||
}
|
||||
Write-Host 'OK: sample 303 ErrorCode 1226 (standard RD Gateway disconnect).'
|
||||
@@ -0,0 +1,8 @@
|
||||
param([string]$Path = (Join-Path $PSScriptRoot '..\Login_Monitor.ps1'))
|
||||
$errs = $null
|
||||
[void][System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path $Path), [ref]$null, [ref]$errs)
|
||||
if ($errs) {
|
||||
$errs | ForEach-Object { $_.ToString() }
|
||||
exit 1
|
||||
}
|
||||
Write-Output 'OK'
|
||||
@@ -0,0 +1,33 @@
|
||||
. (Join-Path $PSScriptRoot '_TestLib.ps1')
|
||||
. (Join-Path $PSScriptRoot '_DeployFunctionsLoader.ps1')
|
||||
$repo = Get-RdpMonitorRepoRoot
|
||||
|
||||
Invoke-RdpMonitorTestCase -Name 'Deploy functions load (RDP_DEPLOY_FUNCTIONS_ONLY)' -Script {
|
||||
Assert-CommandExists -Name 'Initialize-RdpMonitorDeployTaskQuery'
|
||||
Assert-CommandExists -Name 'Test-RdpMonitorDeployMainTaskNeedsUnlimitedExecutionTime'
|
||||
Assert-CommandExists -Name 'Write-RdpMonitorDeployScheduledTaskVerification'
|
||||
Assert-CommandExists -Name 'Get-RdpMonitorDeployTaskExecutionTimeLimitLabelFromResolved'
|
||||
Assert-CommandExists -Name 'Convert-RdpMonitorDeployTaskExecutionTimeLimitValue'
|
||||
}
|
||||
|
||||
Invoke-RdpMonitorTestCase -Name 'Deploy ExecutionTimeLimit accepts PT0S string (Get-ScheduledTask shape)' -Script {
|
||||
Assert-True -Condition (Test-RdpMonitorDeployTaskExecutionLimitUnlimitedValue -Limit 'PT0S') `
|
||||
-Message 'PT0S string must be treated as unlimited'
|
||||
$resolved = [pscustomobject]@{ Limit = 'PT0S'; Source = 'Get-ScheduledTask' }
|
||||
$label = Get-RdpMonitorDeployTaskExecutionTimeLimitLabelFromResolved -Resolved $resolved
|
||||
Assert-True -Condition ($label -eq 'PT0S') -Message "Expected PT0S label, got $label"
|
||||
Assert-True -Condition (Test-RdpMonitorDeployTaskExecutionLimitUnlimitedValue -Limit $resolved.Limit) `
|
||||
-Message 'Resolved PT0S string must pass unlimited check'
|
||||
}
|
||||
|
||||
Invoke-RdpMonitorTestCase -Name 'Deploy pre-check task limit (no throw on early path)' -Script {
|
||||
$needsFix = Test-RdpMonitorDeployMainTaskNeedsUnlimitedExecutionTime -ShareRoot $repo -TaskName 'RDP-Login-Monitor-UnitTest-Missing'
|
||||
Assert-True -Condition ($needsFix -is [bool]) -Message 'Test-RdpMonitorDeployMainTaskNeedsUnlimitedExecutionTime must return bool'
|
||||
}
|
||||
|
||||
Invoke-RdpMonitorTestCase -Name 'Deploy verification after Initialize (no missing command)' -Script {
|
||||
[void](Initialize-RdpMonitorDeployTaskQuery -ShareRoot $repo)
|
||||
Assert-CommandExists -Name 'Get-RdpMonitorScheduledTaskExecutionTimeLimitResolved'
|
||||
$ok = Write-RdpMonitorDeployScheduledTaskVerification -ShareRoot $repo -TaskName 'RDP-Login-Monitor-UnitTest-Missing'
|
||||
Assert-True -Condition ($ok -is [bool]) -Message 'Write-RdpMonitorDeployScheduledTaskVerification must return bool'
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
. (Join-Path $PSScriptRoot '_TestLib.ps1')
|
||||
|
||||
$repo = Get-RdpMonitorRepoRoot
|
||||
$files = @(
|
||||
'Deploy-LoginMonitor.ps1',
|
||||
'Login_Monitor.ps1',
|
||||
'Sac-Client.ps1',
|
||||
'RdpMonitor-TaskQuery.ps1',
|
||||
'update-rdp-monitor.ps1'
|
||||
)
|
||||
|
||||
foreach ($rel in $files) {
|
||||
$path = Join-Path $repo $rel
|
||||
Invoke-RdpMonitorTestCase -Name "Syntax: $rel" -Script {
|
||||
Assert-True -Condition (Test-Path -LiteralPath $path) -Message "Missing $path"
|
||||
$errs = $null
|
||||
[void][System.Management.Automation.Language.Parser]::ParseFile($path, [ref]$null, [ref]$errs)
|
||||
if ($errs -and $errs.Count -gt 0) {
|
||||
throw ($errs | ForEach-Object { $_.ToString() } | Out-String)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
. (Join-Path $PSScriptRoot '_TestLib.ps1')
|
||||
|
||||
function Test-RdpMonitorDeploySacHostLabelStrictMode {
|
||||
Set-StrictMode -Version Latest
|
||||
$hostLabel = [string]$env:COMPUTERNAME
|
||||
if (Get-Variable -Name ServerDisplayName -Scope Script -ErrorAction SilentlyContinue) {
|
||||
$sdn = (Get-Variable -Name ServerDisplayName -Scope Script -ValueOnly)
|
||||
if ($null -ne $sdn -and -not [string]::IsNullOrWhiteSpace([string]$sdn)) {
|
||||
$hostLabel = [string]$sdn.Trim()
|
||||
}
|
||||
}
|
||||
return $hostLabel
|
||||
}
|
||||
|
||||
Invoke-RdpMonitorTestCase -Name 'Deploy SAC host label without ServerDisplayName (StrictMode)' -Script {
|
||||
$label = Test-RdpMonitorDeploySacHostLabelStrictMode
|
||||
Assert-True -Condition (-not [string]::IsNullOrWhiteSpace($label)) -Message 'Host label must not be empty'
|
||||
Assert-True -Condition ($label -eq [string]$env:COMPUTERNAME) -Message 'Expected COMPUTERNAME when ServerDisplayName unset'
|
||||
}
|
||||
|
||||
Invoke-RdpMonitorTestCase -Name 'Deploy SAC host label with ServerDisplayName (StrictMode)' -Script {
|
||||
$script:ServerDisplayName = 'Test-Server-Display'
|
||||
try {
|
||||
$label = Test-RdpMonitorDeploySacHostLabelStrictMode
|
||||
Assert-True -Condition ($label -eq 'Test-Server-Display') -Message 'Expected ServerDisplayName value'
|
||||
} finally {
|
||||
Remove-Variable -Name ServerDisplayName -Scope Script -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-RdpMonitorTestCase -Name 'Login_Monitor -SendDeploySacNotice does not fail on StrictMode host label' -Script {
|
||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
|
||||
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||
Write-Host 'SKIP: requires elevated PowerShell (administrator)'
|
||||
return
|
||||
}
|
||||
|
||||
$repo = Get-RdpMonitorRepoRoot
|
||||
$tempRoot = Join-Path $env:TEMP ("rdp-monitor-test-{0}" -f [guid]::NewGuid().ToString('N'))
|
||||
New-Item -ItemType Directory -Path $tempRoot -Force | Out-Null
|
||||
New-Item -ItemType Directory -Path (Join-Path $tempRoot 'Logs') -Force | Out-Null
|
||||
|
||||
$settings = @'
|
||||
$UseSAC = 'off'
|
||||
'@
|
||||
$settingsPath = Join-Path $tempRoot 'login_monitor.settings.ps1'
|
||||
[System.IO.File]::WriteAllText($settingsPath, $settings, (New-Object System.Text.UTF8Encoding $true))
|
||||
|
||||
Copy-Item -LiteralPath (Join-Path $repo 'Login_Monitor.ps1') -Destination (Join-Path $tempRoot 'Login_Monitor.ps1') -Force
|
||||
Copy-Item -LiteralPath (Join-Path $repo 'Sac-Client.ps1') -Destination (Join-Path $tempRoot 'Sac-Client.ps1') -Force
|
||||
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe"
|
||||
$psi.Arguments = '-NoProfile -ExecutionPolicy Bypass -File "{0}" -SendDeploySacNotice' -f (Join-Path $tempRoot 'Login_Monitor.ps1')
|
||||
$psi.WorkingDirectory = $tempRoot
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
|
||||
$proc = [System.Diagnostics.Process]::Start($psi)
|
||||
$stdout = $proc.StandardOutput.ReadToEnd()
|
||||
$stderr = $proc.StandardError.ReadToEnd()
|
||||
$proc.WaitForExit()
|
||||
|
||||
try {
|
||||
if ($stderr -match 'ServerDisplayName|VariableIsUndefined') {
|
||||
throw "SendDeploySacNotice stderr contains StrictMode error: $stderr"
|
||||
}
|
||||
Assert-True -Condition ($proc.ExitCode -eq 0) -Message "Expected exit 0 with UseSAC=off, got $($proc.ExitCode); stderr=$stderr stdout=$stdout"
|
||||
} finally {
|
||||
Remove-Item -LiteralPath $tempRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
. (Join-Path $PSScriptRoot '_TestLib.ps1')
|
||||
|
||||
$repo = Get-RdpMonitorRepoRoot
|
||||
$taskQuery = Join-Path $repo 'RdpMonitor-TaskQuery.ps1'
|
||||
|
||||
Invoke-RdpMonitorTestCase -Name 'TaskQuery file exists' -Script {
|
||||
Assert-True -Condition (Test-Path -LiteralPath $taskQuery) -Message "Missing $taskQuery"
|
||||
}
|
||||
|
||||
Invoke-RdpMonitorTestCase -Name 'TaskQuery dot-source defines core commands' -Script {
|
||||
. $taskQuery
|
||||
Assert-CommandExists -Name 'Get-RdpMonitorScheduledTaskExecutionTimeLimitResolved'
|
||||
Assert-CommandExists -Name 'Test-RdpMonitorScheduledTaskNeedsUnlimitedExecutionTimeLimit'
|
||||
Assert-CommandExists -Name 'Get-RdpMonitorScheduledTaskExecutionTimeLimitLabel'
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
# Dot-source from a test .ps1 at script scope after _TestLib.ps1 (not from a function/scriptblock).
|
||||
if ($script:RdpMonitorDeployFunctionsLoaded) { return }
|
||||
|
||||
$repo = Get-RdpMonitorRepoRoot
|
||||
$prev = $env:RDP_DEPLOY_FUNCTIONS_ONLY
|
||||
$env:RDP_DEPLOY_FUNCTIONS_ONLY = '1'
|
||||
try {
|
||||
. (Join-Path $repo 'Deploy-LoginMonitor.ps1')
|
||||
} finally {
|
||||
if ($null -eq $prev) {
|
||||
Remove-Item Env:RDP_DEPLOY_FUNCTIONS_ONLY -ErrorAction SilentlyContinue
|
||||
} else {
|
||||
$env:RDP_DEPLOY_FUNCTIONS_ONLY = $prev
|
||||
}
|
||||
}
|
||||
$script:RdpMonitorDeployFunctionsLoaded = $true
|
||||
@@ -0,0 +1,39 @@
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$script:RdpMonitorDeployFunctionsLoaded = $false
|
||||
|
||||
function Get-RdpMonitorRepoRoot { return (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||
}
|
||||
|
||||
function Assert-True {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][bool]$Condition,
|
||||
[Parameter(Mandatory = $true)][string]$Message
|
||||
)
|
||||
if (-not $Condition) {
|
||||
throw "FAIL: $Message"
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-CommandExists {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Name
|
||||
)
|
||||
$cmd = Get-Command -Name $Name -ErrorAction SilentlyContinue
|
||||
Assert-True -Condition ($null -ne $cmd) -Message "Command not found: $Name"
|
||||
}
|
||||
|
||||
function Invoke-RdpMonitorTestCase {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Name,
|
||||
[Parameter(Mandatory = $true)][scriptblock]$Script
|
||||
)
|
||||
try {
|
||||
& $Script
|
||||
Write-Host "PASS: $Name"
|
||||
} catch {
|
||||
Write-Host "FAIL: $Name - $($_.Exception.Message)"
|
||||
throw
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Obnovlyaet klon RDP-login-monitor s upstream git i kopiruet dist na NETLOGON.
|
||||
.DESCRIPTION
|
||||
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.
|
||||
Kopiruyutsya: polnyj spisok v Docs/deploy-netlogon-publish.md.
|
||||
.EXAMPLE
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\soft\update-rdp-monitor.ps1
|
||||
.EXAMPLE
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\soft\update-rdp-monitor.ps1 -WhatIf
|
||||
#>
|
||||
[CmdletBinding(SupportsShouldProcess = $true)]
|
||||
param(
|
||||
[string]$RepoPath = 'C:\Soft\Git\RDP-login-monitor',
|
||||
[string]$NetlogonDest = '\\b26\NETLOGON\RDP-login-monitor',
|
||||
[string]$GitUrl = 'https://git.kalinamall.ru/PapaTramp/RDP-login-monitor.git',
|
||||
[string]$GitBranch = 'main',
|
||||
[string]$LogFile = 'C:\soft\Logs\update-rdp-monitor.log',
|
||||
[switch]$SkipGitClean
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$DistFiles = @(
|
||||
'Login_Monitor.ps1',
|
||||
'Sac-Client.ps1',
|
||||
'RdpMonitor-TaskQuery.ps1',
|
||||
'version.txt',
|
||||
'Deploy-LoginMonitor.ps1',
|
||||
'Restart-RdpLoginMonitor.ps1',
|
||||
'Exchange-MailSecurity.ps1',
|
||||
'Notify-Common.ps1',
|
||||
'Install-DomainMonitors.ps1',
|
||||
'Deploy-DomainMonitors.ps1',
|
||||
'exchange_monitor.settings.example.ps1',
|
||||
'Diagnose-RdpLoginMonitor.ps1',
|
||||
'login_monitor.settings.example.ps1'
|
||||
)
|
||||
|
||||
function Write-UpdateLog {
|
||||
param([string]$Message)
|
||||
$line = '[{0}] {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message
|
||||
Write-Host $line
|
||||
$dir = Split-Path -Parent $LogFile
|
||||
if (-not (Test-Path -LiteralPath $dir)) {
|
||||
New-Item -ItemType Directory -Path $dir -Force | Out-Null
|
||||
}
|
||||
$utf8Bom = New-Object System.Text.UTF8Encoding $true
|
||||
[System.IO.File]::AppendAllText($LogFile, $line + [Environment]::NewLine, $utf8Bom)
|
||||
}
|
||||
|
||||
function Invoke-GitCommand {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string[]]$Arguments,
|
||||
[string]$WorkingDirectory = $RepoPath
|
||||
)
|
||||
$prevEap = $ErrorActionPreference
|
||||
$ErrorActionPreference = 'Continue'
|
||||
try {
|
||||
Push-Location -LiteralPath $WorkingDirectory
|
||||
# stderr git (progress) ne dolzhen lomat skript v PowerShell 5.1
|
||||
$raw = & git @Arguments 2>&1
|
||||
$code = $LASTEXITCODE
|
||||
} finally {
|
||||
Pop-Location
|
||||
$ErrorActionPreference = $prevEap
|
||||
}
|
||||
$lines = @()
|
||||
foreach ($item in @($raw)) {
|
||||
if ($null -eq $item) { continue }
|
||||
if ($item -is [System.Management.Automation.ErrorRecord]) {
|
||||
$lines += $item.ToString()
|
||||
} else {
|
||||
$lines += [string]$item
|
||||
}
|
||||
}
|
||||
foreach ($line in $lines) {
|
||||
if ($line.Length -gt 0) {
|
||||
Write-UpdateLog "git: $line"
|
||||
}
|
||||
}
|
||||
if ($code -ne 0) {
|
||||
throw ("git {0} failed (exit {1})" -f ($Arguments -join ' '), $code)
|
||||
}
|
||||
return $lines
|
||||
}
|
||||
|
||||
function Get-GitShortHead {
|
||||
$lines = Invoke-GitCommand -Arguments @('rev-parse', '--short', 'HEAD')
|
||||
foreach ($line in $lines) {
|
||||
$t = $line.Trim()
|
||||
if ($t -match '^[0-9a-f]{7,40}$') {
|
||||
return $t
|
||||
}
|
||||
}
|
||||
return ($lines -join ' ').Trim()
|
||||
}
|
||||
|
||||
function Ensure-GitAvailable {
|
||||
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
|
||||
throw 'git.exe not found in PATH. Install Git for Windows.'
|
||||
}
|
||||
}
|
||||
|
||||
function Ensure-GitRepository {
|
||||
$gitDir = Join-Path $RepoPath '.git'
|
||||
if (-not (Test-Path -LiteralPath $gitDir)) {
|
||||
throw "Not a git repo (no .git): $RepoPath. Clone first: git clone $GitUrl `"$RepoPath`""
|
||||
}
|
||||
}
|
||||
|
||||
function Get-ConfiguredGitRemoteName {
|
||||
$names = @(Invoke-GitCommand -Arguments @('remote') | ForEach-Object { "$_".Trim() } | Where-Object { $_ })
|
||||
if ($names.Count -eq 0) { return $null }
|
||||
if ('kalinamall' -in $names) { return 'kalinamall' }
|
||||
foreach ($n in $names) {
|
||||
$url = (& git -C $RepoPath remote get-url $n 2>$null)
|
||||
if ($url -match 'git\.kalinamall\.ru') { return $n }
|
||||
}
|
||||
if ('origin' -in $names) { return 'origin' }
|
||||
return $names[0]
|
||||
}
|
||||
|
||||
function Ensure-GitKalinamallRemote {
|
||||
$name = Get-ConfiguredGitRemoteName
|
||||
if ($null -ne $name) {
|
||||
$url = (& git -C $RepoPath remote get-url $name 2>$null)
|
||||
if ($url -match 'git\.kalinamall\.ru') {
|
||||
Write-UpdateLog "Using remote: $name ($url)"
|
||||
return $name
|
||||
}
|
||||
Write-UpdateLog "Remote $name is not kalinamall ($url); adding kalinamall -> $GitUrl"
|
||||
} else {
|
||||
Write-UpdateLog "No remotes; adding kalinamall -> $GitUrl"
|
||||
}
|
||||
if ('kalinamall' -in @(& git -C $RepoPath remote 2>$null)) {
|
||||
Invoke-GitCommand -Arguments @('remote', 'set-url', 'kalinamall', $GitUrl)
|
||||
return 'kalinamall'
|
||||
}
|
||||
Invoke-GitCommand -Arguments @('remote', 'add', 'kalinamall', $GitUrl)
|
||||
return 'kalinamall'
|
||||
}
|
||||
|
||||
function Update-Repository {
|
||||
Ensure-GitRepository
|
||||
$remote = Ensure-GitKalinamallRemote
|
||||
Invoke-GitCommand -Arguments @('fetch', '--prune', $remote, $GitBranch)
|
||||
$upstream = "${remote}/${GitBranch}"
|
||||
|
||||
$dirty = @(Invoke-GitCommand -Arguments @('status', '--porcelain') | Where-Object { "$_".Trim().Length -gt 0 })
|
||||
if ($dirty.Count -gt 0) {
|
||||
Write-UpdateLog "WARN: discarding $($dirty.Count) local change(s) on publish clone (reset --hard + clean)"
|
||||
foreach ($d in $dirty) {
|
||||
Write-UpdateLog " $d"
|
||||
}
|
||||
}
|
||||
|
||||
$mergeHead = Join-Path $RepoPath '.git\MERGE_HEAD'
|
||||
if (Test-Path -LiteralPath $mergeHead) {
|
||||
Write-UpdateLog 'WARN: incomplete merge — aborting before sync'
|
||||
Invoke-GitCommand -Arguments @('merge', '--abort')
|
||||
}
|
||||
|
||||
Invoke-GitCommand -Arguments @('reset', '--hard', $upstream)
|
||||
if (-not $SkipGitClean) {
|
||||
Invoke-GitCommand -Arguments @('clean', '-fd')
|
||||
}
|
||||
|
||||
$remoteHead = (Invoke-GitCommand -Arguments @('rev-parse', '--short', $upstream) | Where-Object { $_ -match '^[0-9a-f]{7,40}$' } | Select-Object -First 1)
|
||||
$head = Get-GitShortHead
|
||||
Write-UpdateLog "HEAD: $head (target $upstream = $remoteHead)"
|
||||
}
|
||||
|
||||
function Copy-FileToNetlogon {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$SourcePath,
|
||||
[Parameter(Mandatory = $true)][string]$DestPath
|
||||
)
|
||||
if ($SourcePath -like '*.ps1') {
|
||||
$raw = [System.IO.File]::ReadAllBytes($SourcePath)
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
|
||||
$text = $utf8NoBom.GetString($raw)
|
||||
if ($text.Length -gt 0 -and [int][char]$text[0] -eq 0xFEFF) {
|
||||
$text = $text.Substring(1)
|
||||
}
|
||||
$utf8Bom = New-Object System.Text.UTF8Encoding $true
|
||||
[System.IO.File]::WriteAllText($DestPath, $text, $utf8Bom)
|
||||
return
|
||||
}
|
||||
Copy-Item -LiteralPath $SourcePath -Destination $DestPath -Force
|
||||
}
|
||||
|
||||
function Publish-DistributionFiles {
|
||||
if (-not (Test-Path -LiteralPath $NetlogonDest)) {
|
||||
if ($PSCmdlet.ShouldProcess($NetlogonDest, 'Create directory')) {
|
||||
New-Item -ItemType Directory -Path $NetlogonDest -Force | Out-Null
|
||||
Write-UpdateLog "Created: $NetlogonDest"
|
||||
}
|
||||
}
|
||||
$repoVer = Join-Path $RepoPath 'version.txt'
|
||||
if (-not (Test-Path -LiteralPath $repoVer)) {
|
||||
throw "Missing version.txt in repo: $repoVer"
|
||||
}
|
||||
$expectedVer = (Get-Content -LiteralPath $repoVer -Raw).Trim()
|
||||
Write-UpdateLog "Repo version.txt: $expectedVer"
|
||||
|
||||
foreach ($name in $DistFiles) {
|
||||
$src = Join-Path $RepoPath $name
|
||||
if (-not (Test-Path -LiteralPath $src)) {
|
||||
throw "Missing in repo: $src"
|
||||
}
|
||||
$dst = Join-Path $NetlogonDest $name
|
||||
if ($PSCmdlet.ShouldProcess($dst, "Copy from $src")) {
|
||||
Copy-FileToNetlogon -SourcePath $src -DestPath $dst
|
||||
Write-UpdateLog "Copied: $name -> $NetlogonDest"
|
||||
}
|
||||
}
|
||||
$verFile = Join-Path $NetlogonDest 'version.txt'
|
||||
if (Test-Path -LiteralPath $verFile) {
|
||||
$ver = (Get-Content -LiteralPath $verFile -Raw).Trim()
|
||||
Write-UpdateLog "NETLOGON version: $ver"
|
||||
if ($ver -ne $expectedVer) {
|
||||
Write-UpdateLog "ERROR: NETLOGON version mismatch (expected $expectedVer)"
|
||||
throw "NETLOGON version.txt is $ver, expected $expectedVer"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
Write-UpdateLog '=== RDP-login-monitor: git pull -> NETLOGON ==='
|
||||
Ensure-GitAvailable
|
||||
Update-Repository
|
||||
Publish-DistributionFiles
|
||||
Write-UpdateLog '=== Done ==='
|
||||
exit 0
|
||||
} catch {
|
||||
Write-UpdateLog "ERROR: $($_.Exception.Message)"
|
||||
exit 1
|
||||
}
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1.4.3
|
||||
2.1.15-SAC
|
||||
|
||||
Reference in New Issue
Block a user