From 9ab6cc64a166115bb2a40f364f82e309c679a586 Mon Sep 17 00:00:00 2001 From: PTah Date: Fri, 22 May 2026 14:32:50 +1000 Subject: [PATCH] docs: add Docs/ guides; Exchange Mail Security scripts (v1.6.0) - Docs: RDP deploy, Exchange mail security, NETLOGON publish - Exchange-MailSecurity.ps1, Notify-Common, Deploy/Install-DomainMonitors - DEPLOY.md redirects to Docs/ --- DEPLOY.md | 231 +------- Deploy-DomainMonitors.ps1 | 148 ++++++ Docs/README.md | 13 + Docs/deploy-netlogon-publish.md | 33 ++ Docs/deploy-rdp-login-monitor.md | 118 +++++ Docs/exchange-mail-security.md | 227 ++++++++ Exchange-MailSecurity.ps1 | 733 ++++++++++++++++++++++++++ Install-DomainMonitors.ps1 | 51 ++ Notify-Common.ps1 | 214 ++++++++ README.md | 5 +- README_eng.md | 5 +- exchange_monitor.settings.example.ps1 | 34 ++ update-rdp-monitor.ps1 | 13 +- version.txt | 2 +- 14 files changed, 1597 insertions(+), 230 deletions(-) create mode 100644 Deploy-DomainMonitors.ps1 create mode 100644 Docs/README.md create mode 100644 Docs/deploy-netlogon-publish.md create mode 100644 Docs/deploy-rdp-login-monitor.md create mode 100644 Docs/exchange-mail-security.md create mode 100644 Exchange-MailSecurity.ps1 create mode 100644 Install-DomainMonitors.ps1 create mode 100644 Notify-Common.ps1 create mode 100644 exchange_monitor.settings.example.ps1 diff --git a/DEPLOY.md b/DEPLOY.md index e8ddbca..f498ade 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -1,226 +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`** (логика описана в разделе «Версии»). - -На сервере публикации (например DC3): клон в `C:\Soft\Git\RDP-login-monitor`, скрипт **`update-rdp-monitor.ps1`** — при отсутствии remote сам добавит `origin` на `https://git.kalinamall.ru/PapaTramp/RDP-login-monitor.git`, выполнит `git pull` и скопирует три файла в `\\b26\NETLOGON\RDP-login-monitor\`. Лог: `C:\soft\Logs\update-rdp-monitor.log`. - -## Как это работает - -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 и/или Email — в зависимости от настроенных каналов) для отдельных событий Security: - -- по умолчанию — **`4624`/`4625`** (шум от известной рабочей станции, тестового пользователя, фиксированного IP); -- с префиксом **`4740:`** / **`lockout:`** — только блокировки учётной записи (**4740**), в т.ч. по IP из IIS ActiveSync; -- с префиксом **`all:`** — и входы, и **4740**. - -Подробный синтаксис — в **[README.md](README.md)** (раздел 7) и **`ignore.lst.example`**. - -- **`Deploy-LoginMonitor.ps1` с шары `ignore.lst` не доставляет** — при необходимости создавайте файл локально или копируйте своим способом. - -## Опционально на контроллере домена: блокировки AD (4740) - -Мониторинг **Security 4740** включается только на том КД, где **установлен и запущен** `Login_Monitor.ps1`, и только если короткое имя узла совпадает с **`$LockoutMonitorDomainController`** в скрипте (после деплоя с шары параметры задаются в локальной копии `Login_Monitor.ps1` на КД). - -Типичная настройка на КД: - -- **`$LockoutMonitorDomainController`** — имя этого контроллера (например `DC01`); -- **`$NetBiosDomainName`**, **`$ExchangeIisLogPath`** — каталог IIS-логов Exchange (ActiveSync, строки **401**); -- **`$ExchangeIisLogMinutesBeforeLockout`** (по умолчанию 30), **`$ExchangeIisLogTailLines`** (5000), **`$ExchangeServerHostForIisExclude`** — при необходимости. - -Deploy с NETLOGON обновляет только **`Login_Monitor.ps1`**; пути IIS и имя КД обычно прописывают **один раз** в локальном файле на КД (или через GPO/Configuration Manager), не на всех членах домена. - -## Heartbeat и «зависший» монитор - -Файл **`Logs\last_heartbeat.txt`** обновляется по интервалу **`$HeartbeatInterval`** (по умолчанию раз в час). Если обновления нет дольше **`$HeartbeatStaleAlertMultiplier` × $HeartbeatInterval`** (по умолчанию **2×1 ч**), уходит оповещение в настроенные каналы (Telegram/Email). Это не заменяет watchdog-задачу, но сигнализирует, что процесс мог зависнуть без обновления heartbeat. - -## Задачи планировщика после `-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). - -## Внутренний репозиторий (git.kalinamall.ru) - -Рабочая копия **`Login_Monitor.ps1`** для домена может содержать **Telegram token/chat id**, **`$LockoutMonitorDomainController`**, **`$NetBiosDomainName`**, **`$ExchangeIisLogPath`** и **`$NotifyOrder`** в открытом виде. - -- Дистрибутив на шару `NETLOGON` берите из **закрытого** репозитория (`kalinamall` remote), не из публичного GitHub. -- В **`origin` (GitHub)** не пушьте файл с секретами: перед `git push origin` верните плейсхолдеры или используйте отдельную ветку только для kalinamall. -- После правок поднимайте **`version.txt`** на шаре (сейчас синхронно с **`$ScriptVersion`** в скрипте). - -## Безопасность и замечания - -- ACL на шару: чтение только нужным **компьютерам** / группам; файл **`Login_Monitor.ps1`** может содержать токен — ограничивайте доступ. -- DPAPI-секреты привязаны к машине: шифровать на каждой цели (Telegram token/chat id, пароль SMTP) или использовать plain на закрытой шаре (см. комментарии в **`Login_Monitor.ps1`**, helper **`Encrypt-DpapiForRdpMonitor.ps1`**). -- Deploy при ошибках пишет в **`deploy.log`** и завершается с кодом **0**, чтобы не блокировать загрузку ОС; проблемы смотрите по логу на ПК. +Оглавление: **[Docs/README.md](Docs/README.md)** diff --git a/Deploy-DomainMonitors.ps1 b/Deploy-DomainMonitors.ps1 new file mode 100644 index 0000000..437c1af --- /dev/null +++ b/Deploy-DomainMonitors.ps1 @@ -0,0 +1,148 @@ +<# +.SYNOPSIS + Доставка скриптов доменных мониторов (Exchange / AD) с шары в ProgramData. +.PARAMETER Target + Exchange — Exchange-MailSecurity.ps1 + Notify-Common.ps1; Ad — зарезервировано. +#> +[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' +) + +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 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 'Укажите -SourceShareRoot или запускайте скрипт с UNC-шары.' + } + 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 пока не поддерживается.' } +} + +$sourceVersionFile = Join-Path $shareRoot $VersionFileName +if (-not (Test-Path -LiteralPath $sourceVersionFile)) { + Write-DeployLog "ОШИБКА: нет $sourceVersionFile на шаре." + exit 1 +} + +$shareVer = Read-VersionLineFromFile -Path $sourceVersionFile +$localVer = Read-VersionLineFromFile -Path $VersionStampPath +Write-DeployLog "Шара version=$shareVer локальная метка=$localVer" + +if (-not [string]::IsNullOrWhiteSpace($localVer)) { + $cmp = Compare-SemVerLike -A $shareVer -B $localVer + if ($cmp -eq 0) { + Write-DeployLog 'Версия совпадает — копирование не требуется.' + if (-not $SkipInstallTasks) { + Write-DeployLog 'Проверка InstallTasks...' + 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 'Версия на шаре старее локальной — пропуск (AllowDowngrade).' + exit 0 + } +} + +if (-not (Test-Path -LiteralPath $InstallRoot)) { + if ($WhatIf) { Write-DeployLog "WhatIf: создать $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 "ОШИБКА: на шаре нет $name" + exit 1 + } + if ($WhatIf) { + Write-DeployLog "WhatIf: copy $src -> $dst" + continue + } + Copy-Item -LiteralPath $src -Destination $dst -Force + Write-DeployLog "Скопировано: $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 завершился с кодом $LASTEXITCODE" + exit $LASTEXITCODE + } + } +} + +Write-DeployLog 'Deploy-DomainMonitors завершён.' +exit 0 diff --git a/Docs/README.md b/Docs/README.md new file mode 100644 index 0000000..7244c41 --- /dev/null +++ b/Docs/README.md @@ -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)**. diff --git a/Docs/deploy-netlogon-publish.md b/Docs/deploy-netlogon-publish.md new file mode 100644 index 0000000..cf180c7 --- /dev/null +++ b/Docs/deploy-netlogon-publish.md @@ -0,0 +1,33 @@ +# Публикация пакета на NETLOGON + +Скрипт **`update-rdp-monitor.ps1`** на сервере публикации (например DC3) выполняет `git pull` и копирует файлы в шару. + +## Параметры по умолчанию + +| Параметр | Значение | +|----------|----------| +| `$RepoPath` | `C:\Soft\Git\RDP-login-monitor` | +| `$NetlogonDest` | `\\b26\NETLOGON\RDP-login-monitor` | +| `$GitUrl` | `https://git.kalinamall.ru/PapaTramp/RDP-login-monitor.git` | +| `$GitBranch` | `main` | +| `$LogFile` | `C:\soft\Logs\update-rdp-monitor.log` | + +## Копируемые файлы + +- `Login_Monitor.ps1` +- `version.txt` +- `Deploy-LoginMonitor.ps1` +- `Exchange-MailSecurity.ps1` +- `Notify-Common.ps1` +- `Install-DomainMonitors.ps1` +- `Deploy-DomainMonitors.ps1` +- `exchange_monitor.settings.example.ps1` + +## Запуск + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\soft\update-rdp-monitor.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\soft\update-rdp-monitor.ps1 -WhatIf +``` + +После pull обязательно проверьте **`version.txt`** на шаре — его номер определяет, подтянут ли обновления на клиентах и Exchange. diff --git a/Docs/deploy-rdp-login-monitor.md b/Docs/deploy-rdp-login-monitor.md new file mode 100644 index 0000000..1a9f6d9 --- /dev/null +++ b/Docs/deploy-rdp-login-monitor.md @@ -0,0 +1,118 @@ +# Развёртывание RDP Login Monitor в домене + +Монитор ставится в **`C:\ProgramData\RDP-login-monitor\`**, задачи планировщика создаёт сам **`Login_Monitor.ps1`** (параметр `-InstallTasks`). Доставку по сети выполняет **`Deploy-LoginMonitor.ps1`**. + +См. также: [exchange-mail-security.md](exchange-mail-security.md) (отдельно, только сервер Exchange). + +## Файлы на файловой шаре + +Создайте каталог, доступный **конечным компьютерам** на чтение (часто учётная запись компьютера домена), например: + +`\\dc.contoso.local\NETLOGON\RDP-login-monitor\` + +Минимум для RDP-монитора на всех ПК/серверах: + +| Файл | Назначение | +|------|------------| +| `Login_Monitor.ps1` | Основной скрипт (токен/chat id plain или DPAPI в параметрах). | +| `version.txt` | **Одна строка** — номер версии пакета на шаре (см. раздел «Версии» ниже). | +| `Deploy-LoginMonitor.ps1` | Установщик: сравнивает версию, копирует монитор, вызывает `-InstallTasks`, при необходимости запускает процесс монитора. | + +Полный список файлов для публикации на шару — в [deploy-netlogon-publish.md](deploy-netlogon-publish.md). + +## Как это работает + +1. **`Deploy-LoginMonitor.ps1`** определяет корень дистрибутива: + - параметр **`-SourceShareRoot`** `\\server\share\RDP-login-monitor`, **или** + - если скрипт запущен по UNC, берётся **родительская папка** этого файла. + +2. Читается **`version.txt`** на шаре и сравнивается с локальной меткой **`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`** — подавление отдельных алертов **4624/4625/4740**. Синтаксис — в **[README.md](../README.md)** (раздел 7) и **`ignore.lst.example`**. Deploy с шары **`ignore.lst` не копирует**. + +## Опционально на КД: блокировки AD (4740) + +Включается только если монитор запущен на КД с именем **`$LockoutMonitorDomainController`**. Параметры задаются **локально** на этом КД после деплоя (`$NetBiosDomainName`, `$ExchangeIisLogPath`, …). + +## Heartbeat и watchdog + +- **`Logs\last_heartbeat.txt`** — обновление по **`$HeartbeatInterval`** (по умолчанию 1 ч). +- Нет обновления дольше **`$HeartbeatStaleAlertMultiplier × интервал`** — оповещение. +- Задача **`RDP-Login-Monitor-Watchdog`** — каждые 5 мин проверяет процесс и поднимает при падении. + +## Задачи планировщика (`-InstallTasks`) + +| Имя | Назначение | +|-----|------------| +| **`RDP-Login-Monitor`** | Запуск при старте ОС | +| **`RDP-Login-Monitor-Watchdog`** | Контроль процесса каждые 5 мин | + +Проверка: + +```powershell +Get-ScheduledTask -TaskName 'RDP-Login-Monitor','RDP-Login-Monitor-Watchdog' -ErrorAction SilentlyContinue +``` + +Логи: **`login_monitor.log`**, **`watchdog.log`**. + +## GPO (автозагрузка): проверенная схема + +1. Файлы на `\\B26\NETLOGON\RDP-login-monitor\` +2. GPO на OU **компьютеров** → **Сценарии PowerShell** автозагрузки → `Deploy-LoginMonitor.ps1` +3. Security Filtering: группа компьютеров (например `B26\RDP-Login`), права Read + Apply GPO +4. Доступ на шару для **SYSTEM** / Domain Computers +5. После смены membership — **перезагрузка** (не только `gpupdate`) + +**Нюансы:** UNC в NETLOGON не копируется в SYSVOL GPO — это нормально. Deploy после `-InstallTasks` делает `schtasks /Run` для немедленного старта монитора. + +### Периодический Deploy на серверах без перезагрузок + +```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 +``` + +### Ручной deploy + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "\\B26\NETLOGON\RDP-login-monitor\Deploy-LoginMonitor.ps1" +``` + +Параметры: **`-WhatIf`**, **`-SkipStartMonitorAfterUpdate`**, **`-AllowDowngrade`**. + +### Диагностика + +- Group Policy Operational log +- `Logs\deploy.log`, `login_monitor.log`, `watchdog.log` + +## Версии: `version.txt` и `$ScriptVersion` + +| Что | Роль | +|-----|------| +| **`version.txt` на шаре** | Триггер обновления для Deploy | +| **`$ScriptVersion` в скрипте** | Версия в логах и Telegram | + +Поднимайте **`version.txt`** при каждой выкладке на NETLOGON. + +## Безопасность + +- Ограничьте ACL на шару (в скрипте могут быть секреты). +- DPAPI: **`Encrypt-DpapiForRdpMonitor.ps1`** +- Внутренний git: не пушить секреты в публичный GitHub + +## UNC и ExecutionPolicy + +При ошибках подписи с FQDN-шары используйте короткое имя DC: `\\DC01\NETLOGON\...` и **`powershell.exe -ExecutionPolicy Bypass -File "..."`**. diff --git a/Docs/exchange-mail-security.md b/Docs/exchange-mail-security.md new file mode 100644 index 0000000..532e1aa --- /dev/null +++ b/Docs/exchange-mail-security.md @@ -0,0 +1,227 @@ +# Exchange Mail Security — руководство + +Скрипт **`Exchange-MailSecurity.ps1`** предназначен **только для сервера Microsoft Exchange** с Exchange Management Shell (EMS). Не устанавливается на все компьютеры домена через GPO RDP-монитора. + +## Назначение + +| Функция | Режим | Расписание (задача планировщика) | +|---------|--------|----------------------------------| +| **Очереди транспорта** | `-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`) + +Для **включённых** правил (`Enabled = $true`) проверяются: + +- `ForwardTo` +- `RedirectTo` +- `ForwardAsAttachmentTo` + +Если целевой SMTP-адрес указывает на домен **не** из `Get-AcceptedDomain` (типы **Authoritative** и **InternalRelay**) и не в **whitelist** — фиксируется находка. + +**Повышенная важность (Критическая):** правило также включает `DeleteMessage` или `MarkAsRead` (типичный паттерн скрытой пересылки при компрометации). + +### 2. Пересылка на уровне ящика (`Get-Mailbox`) + +Проверяются ящики с заполненными: + +- `ForwardingSmtpAddress` +- `ForwardingAddress` + +Это отдельный от Inbox Rule механизм («пересылать всю почту на внешний ящик») — частый вектор BEC. + +### 3. Правила транспорта (`Get-TransportRule`) + +Для **включённых** transport rules проверяются действия с адресами: + +- `RedirectMessageTo` +- `BlindCopyTo` +- `CopyTo` + +При внешнем SMTP — находка с типом **TransportRule**. + +### Что не отслеживается + +- **Отключённые** Inbox rules с внешней пересылкой +- Делегирование, 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`. +- **Первый** полный скан: baseline пуст → алерт по **всем** найденным внешним пересылкам (легитимные партнёры добавьте в whitelist заранее). +- **`$NotifyWhenForwardingScanClean = $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 "\\B26\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 +Copy-Item "C:\ProgramData\RDP-login-monitor\exchange_monitor.settings.example.ps1" ` + "C:\ProgramData\RDP-login-monitor\exchange_monitor.settings.ps1" +notepad C:\ProgramData\RDP-login-monitor\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` | Ограничить список ящиков | +| `$InboxScanBatchSize` | 50 | Пауза каждые N ящиков | +| `$InboxScanBatchDelaySeconds` | 3 | Задержка между батчами | +| `$ExcludeMailboxPatterns` | HealthMailbox*, … | Исключения | + +Переопределение — в **`exchange_monitor.settings.ps1`**. + +## Формат оповещения (пересылка) + +Пример: + +```text +📧 Exchange: пересылка на внешний адрес +Тип: InboxRule | MailboxForwarding | TransportRule +Ящик: user@kalinamall.ru +Правило: … +Куда: attacker@gmail.com (внешний) +Важность: Критическая | Высокая +``` + +## Обновление версии + +1. Поднять **`version.txt`** в репозитории и на шаре. +2. `update-rdp-monitor.ps1` или ручное копирование. +3. На Exchange: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "\\B26\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` | +| Слишком много алертов в первый день | Заполнить `$ExternalDomainWhitelist`; дождаться baseline; или временно `$AlertOnlyOnNewForwardingFindings = $false` только для теста | +| 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 diff --git a/Exchange-MailSecurity.ps1 b/Exchange-MailSecurity.ps1 new file mode 100644 index 0000000..0c582e6 --- /dev/null +++ b/Exchange-MailSecurity.ps1 @@ -0,0 +1,733 @@ +<# +.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()] +param( + [ValidateSet('Queues', 'Inbox', 'Watchdog')] + [string]$Mode = 'Queues', + [switch]$InstallTasks, + [switch]$Watchdog +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# ============================================ +# КОНФИГУРАЦИЯ +# ============================================ + +$ScriptVersion = '1.6.0' +$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 +$VipMailboxesOnly = $false +$VipMailboxes = @() # user@domain.com +$ExcludeMailboxPatterns = @('HealthMailbox*', 'DiscoveryMailbox*', 'SystemMailbox*') +$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-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: зарегистрированы $($script:ScheduledTaskQueues), $($script:ScheduledTaskInbox), $($script:ScheduledTaskWatchdog)" +} + +if ($InstallTasks) { + if (-not (Test-RunningElevated)) { + Write-Host 'InstallTasks: нужны права администратора.' + exit 1 + } + Register-ExchangeMonitorScheduledTasks + exit 0 +} + +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 рядом с $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 '') { $TelegramBotToken = '' } +if ($TelegramChatID -eq '') { $TelegramChatID = '' } + +# ============================================ +# EXCHANGE EMS +# ============================================ + +$script:ExchangeSessionLoaded = $false + +function Import-ExchangeManagementShell { + if ($script:ExchangeSessionLoaded) { return $true } + + $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 $true + } + + $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 $true + } + + 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 $true + } + + throw 'Не удалось загрузить Exchange Management Shell (snap-in / RemoteExchange / укажите $ExchangeServerFqdn).' +} + +# ============================================ +# ВНЕШНИЕ ДОМЕНЫ +# ============================================ + +function Get-InternalAcceptedDomainNames { + 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 +} + +# ============================================ +# FINDINGS +# ============================================ + +function New-ForwardingFinding { + param( + [string]$FindingType, + [string]$Mailbox, + [string]$RuleName, + [string]$TargetAddress, + [string]$Severity, + [hashtable]$Extra = @{} + ) + + $rulePart = if ([string]::IsNullOrWhiteSpace($RuleName)) { '' } else { $RuleName } + $id = '{0}|{1}|{2}|{3}' -f $FindingType, $Mailbox.ToLowerInvariant(), $rulePart, $TargetAddress.ToLowerInvariant() + 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 = "📧 Exchange: пересылка на внешний адрес`r`n" + $msg += "🖥️ Сервер: $hostName`r`n" + $msg += "📋 Тип: $hType`r`n" + $msg += "📬 Ящик: $hMb`r`n" + if (-not [string]::IsNullOrWhiteSpace($Finding.RuleName)) { + $msg += "📎 Правило: $hRule`r`n" + } + $msg += "➡️ Куда: $hTarget (внешний)`r`n" + $msg += "⚠️ Важность: $hSev`r`n" + + if ($Finding.Extra.ContainsKey('Enabled')) { + $msg += "✅ Включено: $(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" + } + + 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: не прочитан ($($_.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 { + Import-ExchangeManagementShell + Write-ExchLog "Queues: порог MessageCount > $QueueMessageCountThreshold" + + $queues = @(Get-Queue -ErrorAction Stop) + $hot = @($queues | Where-Object { $_.MessageCount -gt $QueueMessageCountThreshold }) + $state = Get-QueueAlertState + $now = Get-Date + + if ($hot.Count -eq 0) { + Write-ExchLog "Queues: OK (всего $($queues.Count), выше порога 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, пропуск алерта" + continue + } + + $hId = ConvertTo-TelegramHtml $q.Identity + $msg = "📬 Exchange: очередь транспорта`r`n" + $msg += "🖥️ Сервер: $(ConvertTo-TelegramHtml $env:COMPUTERNAME)`r`n" + $msg += "📋 Очередь: $hId`r`n" + $msg += "📊 Сообщений: $($q.MessageCount) (порог $QueueMessageCountThreshold)`r`n" + $msg += "📌 Статус: $(ConvertTo-TelegramHtml ([string]$q.Status))`r`n" + $msg += "🕐 $(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: алерт отправлен для $($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 { + Import-ExchangeManagementShell + if ($VipMailboxesOnly -and $VipMailboxes.Count -gt 0) { + return @($VipMailboxes | ForEach-Object { $_.Trim().ToLowerInvariant() } | Where-Object { $_ }) + } + + $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 } + $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 ($VipMailboxesOnly -and $VipMailboxes.Count -gt 0) { + if ($VipMailboxes -notcontains $primary.ToLowerInvariant()) { 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 = 'Высокая' + $Findings.Add((New-ForwardingFinding -FindingType 'MailboxForwarding' -Mailbox $primary ` + -RuleName '' -TargetAddress $t -Severity $sev -Extra @{ + DeliverToMailboxAndForward = [string]$mb.DeliverToMailboxAndForward + })) | Out-Null + } + } +} + +function Scan-InboxRulesForMailbox { + param( + [string]$Mailbox, + [System.Collections.Generic.HashSet[string]]$InternalDomains, + [System.Collections.Generic.List[object]]$Findings + ) + + $rules = @(Get-InboxRule -Mailbox $Mailbox -ErrorAction SilentlyContinue) + foreach ($rule in $rules) { + if (-not $rule.Enabled) { continue } + + $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 = 'Высокая' + if ($rule.DeleteMessage -or $rule.MarkAsRead) { $sev = 'Критическая' } + $Findings.Add((New-ForwardingFinding -FindingType 'InboxRule' -Mailbox $Mailbox ` + -RuleName [string]$rule.Name -TargetAddress $addr -Severity $sev -Extra @{ + Enabled = $rule.Enabled + DeleteMessage = $rule.DeleteMessage + MarkAsRead = $rule.MarkAsRead + Property = $p.Name + })) | Out-Null + } + } + } +} + +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 'Высокая' -Extra @{})) | Out-Null + } + } + } +} + +function Invoke-ExchangeInboxAndForwardingScan { + Write-ExchLog "Inbox/Forwarding scan v$ScriptVersion; каналы: $(Get-NotifyChainHuman)" + 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 + + if ($ScanInboxRules) { + $mailboxes = @(Get-MailboxListForScan) + Write-ExchLog "Inbox rules: ящиков к скану: $($mailboxes.Count)" + $idx = 0 + foreach ($mb in $mailboxes) { + $idx++ + try { + Scan-InboxRulesForMailbox -Mailbox $mb -InternalDomains $internalDomains -Findings $findings + } catch { + Write-ExchLog "Inbox rules: ошибка $mb : $($_.Exception.Message)" + } + if ($idx % $InboxScanBatchSize -eq 0) { + Write-ExchLog "Inbox rules: обработано $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: всего $($findings.Count), новых $($newFindings.Count)" + + $toAlert = if ($AlertOnlyOnNewForwardingFindings) { $newFindings } else { @($findings) } + 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 = "✅ Exchange: скан пересылки`r`nВнешняя пересылка (Inbox / mailbox / transport) не обнаружена.`r`n🖥️ $(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: изменений нет (только известные находки в baseline)' + } + + Save-ForwardingBaseline -FindingIds $allIds + Write-TextFileUtf8Bom -Path $InboxHeartbeatFile -Text (Get-Date -Format 'yyyy-MM-dd HH:mm:ss') + Write-ExchLog 'Inbox/Forwarding scan завершён' +} + +# ============================================ +# 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("Очереди: нет успешного скана > $([int]$qAge) с (порог $QueuesHeartbeatStaleSeconds с)") | Out-Null + } + + $iAge = Get-HeartbeatAgeSeconds -Path $InboxHeartbeatFile + if ($iAge -gt $InboxHeartbeatStaleSeconds) { + $issues.Add("Inbox/Forwarding: нет успешного скана > $([int]$iAge) с (порог $InboxHeartbeatStaleSeconds с)") | Out-Null + } + + if ($issues.Count -eq 0) { + Write-WdLog 'Watchdog: heartbeat OK' + exit 0 + } + + $msg = "⚠️ Exchange Mail Security: watchdog`r`n" + $msg += "🖥️ $(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: отправлено оповещение ($($issues.Count) проблем)" + exit 1 +} + +# ============================================ +# MAIN +# ============================================ + +if (-not (Test-RunningElevated)) { + Write-ExchLog 'ПРЕДУПРЕЖДЕНИЕ: скрипт без прав администратора — EMS/задачи могут не работать.' +} + +Write-ExchLog "=== Exchange-MailSecurity v$ScriptVersion Mode=$Mode ===" + +try { + switch ($Mode) { + 'Queues' { Invoke-ExchangeQueueScan } + 'Inbox' { Invoke-ExchangeInboxAndForwardingScan } + 'Watchdog' { Invoke-ExchangeWatchdog } + default { throw "Неизвестный Mode: $Mode" } + } +} catch { + Write-ExchLog "ОШИБКА: $($_.Exception.Message)" + if ($_.ScriptStackTrace) { Write-ExchLog $_.ScriptStackTrace } + exit 1 +} + +exit 0 diff --git a/Install-DomainMonitors.ps1 b/Install-DomainMonitors.ps1 new file mode 100644 index 0000000..144db12 --- /dev/null +++ b/Install-DomainMonitors.ps1 @@ -0,0 +1,51 @@ +<# +.SYNOPSIS + Регистрирует задачи планировщика для доменных мониторов (Exchange; AD — позже). +.PARAMETER Target + Exchange — Exchange-MailSecurity.ps1; Ad — зарезервировано. +#> +[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 'Запустите Install-DomainMonitors.ps1 от имени администратора.' +} + +switch ($Target) { + 'Exchange' { + $scriptPath = Join-Path $InstallRoot 'Exchange-MailSecurity.ps1' + if (-not (Test-Path -LiteralPath $scriptPath)) { + throw "Не найден: $scriptPath" + } + $notifyPath = Join-Path $InstallRoot 'Notify-Common.ps1' + if (-not (Test-Path -LiteralPath $notifyPath)) { + throw "Не найден: $notifyPath" + } + & $PsExe -NoProfile -ExecutionPolicy Bypass -File $scriptPath -InstallTasks + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + Write-Host "Exchange: задачи планировщика зарегистрированы." + } + 'Ad' { + Write-Host 'AD-SecurityMonitor.ps1 пока не реализован — используйте Target Exchange.' + exit 1 + } +} + +exit 0 diff --git a/Notify-Common.ps1 b/Notify-Common.ps1 new file mode 100644 index 0000000..c572edc --- /dev/null +++ b/Notify-Common.ps1 @@ -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: неизвестный канал '$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 'нет (ни Telegram, ни SMTP не настроены)' } + $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: не задан токен/chat_id" + 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: $($_.Exception.Message)" + return $false + } +} + +function ConvertTo-EmailHtmlBody { + param([string]$TelegramHtmlMessage) + $inner = [string]$TelegramHtmlMessage + if ([string]::IsNullOrEmpty($inner)) { $inner = '' } + $inner = $inner -replace "`r`n", "
`r`n" + return @" + + +$inner + + +"@ +} + +function Send-EmailNotification { + param( + [string]$Message, + [string]$Subject = "RDP Login Monitor" + ) + + if (-not (Test-NotifyEmailConfigured)) { + Write-NotifyLog "Email: SMTP не настроен" + 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: $($_.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 "Оповещение не отправлено: нет каналов" + 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 +} diff --git a/README.md b/README.md index 838efa0..dc7c815 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ PowerShell-набор для мониторинга входов в Windows с - `RDP-Login-Monitor` (основной монитор), - `RDP-Login-Monitor-Watchdog` (контроль процесса каждые 5 минут). - Доменная доставка и обновления: **`Deploy-LoginMonitor.ps1`** + **`version.txt`** с шары `NETLOGON`. После успешного деплоя в приветственном сообщении (Telegram/Email) может появиться отметка об обновлении (файл **`deploy_last_update.txt`** рядом с логами). -- Для полной инструкции по деплою/GPO используйте **[DEPLOY.md](DEPLOY.md)**. +- Документация по развёртыванию: **[Docs/README.md](Docs/README.md)** (RDP-монитор, Exchange, NETLOGON). - **`Encrypt-DpapiForRdpMonitor.ps1`** — опционально для подготовки DPAPI-строк токена/chat id и пароля SMTP (`$MailSmtpPasswordProtectedB64`). ## Что изменилось (важное) @@ -20,6 +20,7 @@ PowerShell-набор для мониторинга входов в Windows с - **`auditpol` на русской Windows**: настройка/проверка аудита опирается на категорию **`Вход/выход`** и подкатегории **`Вход в систему` / `Выход из системы`** (ожидается строка **`Успех и сбой`**). Это устраняет ошибки вида `0x00000057` из‑за несуществующего на RU ОС имени `Logon`. - **Стабильность**: `auditpol` вызывается по полному пути `%SystemRoot%\System32\auditpol.exe` (без зависимости от PATH), stdout+stderr объединяются через `ProcessStartInfo`. - **Агрегация 4625 (брутфорс)**: при включённом `$FailedLogonRateLimitEnabled` — уровень 1: **5** неудачных попыток за **60** с с одного источника для **одной** учётной записи (IP+user); уровень 2: **12** попыток за **60** с с одного IP (несколько логинов). Пока порог не достигнут — поштучные 4625; при всплеске — сводные алерты, одиночные подавляются. Параметры в начале `Login_Monitor.ps1`. Автоблокировка IP не выполняется. +- **Exchange Mail Security** (`Exchange-MailSecurity.ps1`): на **сервере Exchange** — очереди, пересылка на внешние адреса, watchdog. Руководство: **[Docs/exchange-mail-security.md](Docs/exchange-mail-security.md)**. ## 1) Подготовка @@ -61,7 +62,7 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\RDP-logi Скрипт сам зарегистрирует `RDP-Login-Monitor` и `RDP-Login-Monitor-Watchdog`, а также запросит немедленный первый запуск задач. -Для доменной установки/обновления с шары вручную ничего в планировщике на клиенте настраивать не требуется: используйте `Deploy-LoginMonitor.ps1` (подробно в `DEPLOY.md`). +Для доменной установки/обновления с шары вручную ничего в планировщике на клиенте настраивать не требуется: используйте `Deploy-LoginMonitor.ps1` (подробно в [Docs/deploy-rdp-login-monitor.md](Docs/deploy-rdp-login-monitor.md)). ## 4) Что проверять после запуска diff --git a/README_eng.md b/README_eng.md index 6f21136..43b6425 100644 --- a/README_eng.md +++ b/README_eng.md @@ -10,7 +10,7 @@ PowerShell toolkit for monitoring Windows logons with Telegram and/or Email (SMT - `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 notification may include an update note (file **`deploy_last_update.txt`** next to logs). -- Full deploy/GPO guidance: **[DEPLOY.md](DEPLOY.md)**. +- 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`). ## Notable behavior @@ -20,6 +20,7 @@ PowerShell toolkit for monitoring Windows logons with Telegram and/or Email (SMT - **`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 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 @@ -61,7 +62,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 diff --git a/exchange_monitor.settings.example.ps1 b/exchange_monitor.settings.example.ps1 new file mode 100644 index 0000000..1584e95 --- /dev/null +++ b/exchange_monitor.settings.example.ps1 @@ -0,0 +1,34 @@ +<# +.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 = @('ceo@kalinamall.ru', 'cfo@kalinamall.ru') + +# Удалённый EMS (если скрипт не на Exchange) +# $ExchangeServerFqdn = 'fifth.kalinamall.ru' diff --git a/update-rdp-monitor.ps1 b/update-rdp-monitor.ps1 index df938f8..5f7e11a 100644 --- a/update-rdp-monitor.ps1 +++ b/update-rdp-monitor.ps1 @@ -4,7 +4,7 @@ .DESCRIPTION Dlya servera publikatsii (napr. DC3). Ne trebuet imenovaniya remote: pri otsutstvii dobavlyaetsya origin ili vypolnyaetsya git pull po URL. - Kopiruyutsya: Login_Monitor.ps1, version.txt, Deploy-LoginMonitor.ps1. + Kopiruyutsya: polnyj spisok v Docs/deploy-netlogon-publish.md. .EXAMPLE powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\soft\update-rdp-monitor.ps1 .EXAMPLE @@ -20,7 +20,16 @@ param( ) $ErrorActionPreference = 'Stop' -$DistFiles = @('Login_Monitor.ps1', 'version.txt', 'Deploy-LoginMonitor.ps1') +$DistFiles = @( + 'Login_Monitor.ps1', + 'version.txt', + 'Deploy-LoginMonitor.ps1', + 'Exchange-MailSecurity.ps1', + 'Notify-Common.ps1', + 'Install-DomainMonitors.ps1', + 'Deploy-DomainMonitors.ps1', + 'exchange_monitor.settings.example.ps1' +) function Write-UpdateLog { param([string]$Message) diff --git a/version.txt b/version.txt index f0ed379..dc1e644 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.5.10 +1.6.0