Compare commits

..

4 Commits

Author SHA1 Message Date
PTah 8f25ca78ad readme and docs editing 2026-05-22 09:19:26 +10:00
PTah 1b192cea7c feat: ignore.lst for 4740, IIS time window, stale heartbeat alert (v1.5.3)
- ignore.lst scopes: 4740:/all: prefixes; IP rules match IIS client IPs
- IIS ActiveSync IPs only within minutes before lockout event
- Alert when last_heartbeat.txt is older than 2x HeartbeatInterval

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 20:46:35 +10:00
PTah 2c73526f7c feat: AD lockout 4740 with IIS IPs and SMTP notifications (v1.5.2)
Monitor Security 4740 on a configured domain controller and enrich alerts
with ActiveSync client IPs from IIS logs. Add unified Telegram and email
delivery with NotifyOrder. Update README and DPAPI helper for mail password.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 19:47:23 +10:00
PTah 4015f9a7e4 fix: refresh ignore.lst each monitor loop for log/mtime (v1.4.4)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 10:22:03 +10:00
52 changed files with 1073 additions and 10049 deletions
+28
View File
@@ -0,0 +1,28 @@
---
description: Global token-saving, cost control, and context management rules
globs: *
---
# Global Optimization & Cost Control
## ?? Context & Local Data
- **Strict file targeting:** Work ONLY with files explicitly provided via `@` or open in the active editor tab. Do not use global codebase search unless requested.
- **Local assets only:** Always work with local copies of repositories and dependencies. Never request external web resources or re-download packages without a explicit build error.
- **Size Limit:** Do not load files larger than 500 lines into the context unless strictly necessary.
## ?? Git & Commits Management
- **Automatic commits are strictly FORBIDDEN.** Never execute `git commit` or `git push` without an explicit user command.
- Only suggest a commit after phrases like: "Ñäåëàé êîììèò", "Çàôèêñèðóé èçìåíåíèÿ", "Push".
- Before committing: display `git diff --stat` and wait for explicit user confirmation.
- Use Conventional Commits format (`feat:`, `fix:`, `refactor:`, `docs:`).
## ?? Output Format & Brevity
- **Strictly no fluff:** Omit greetings, apologies, and closing pleasantries.
- **Diff-style only:** Output only modified code fragments, never duplicate unchanged logic or whole files.
- **Extreme brevity:** Explanations must be 1-3 sentences max. Use documentation links instead of long texts.
- If a task doesn't require code, respond strictly with text. If in doubt, ask ONE precise clarifying question.
## ?? Model Routing Reminder
- Simple questions, explanations, docs ? Use lightweight models (e.g., `gpt-4o-mini`, `claude-3-haiku`).
- Complex code generation, refactoring, and debugging ? Use advanced models (e.g., `claude-3.5-sonnet`).
- Do not switch models without an explicit reason.
+13
View File
@@ -0,0 +1,13 @@
---
description: Code generation syntax restrictions to minimize output tokens
globs: "*.{ts,js,py,cs}"
---
# Syntax Optimization
- **No JSDoc/Docstrings:** Do NOT write documentation, comments, JSDoc, or docstrings for generated functions unless explicitly asked.
- **No logs or prints:** Remove all `console.log`, `print()`, or debugging statements from the final code output.
- **Use concise syntax:**
- In JavaScript/TypeScript: Use arrow functions, optional chaining (`?.`), nullish coalescing (`??`), and destructuring.
- In Python: Use list comprehensions, dict comprehensions, and built-in functions.
- **Minimize imports:** Do not output the `import` statements section if the required packages are standard and already exist in the file.
+18
View File
@@ -0,0 +1,18 @@
---
description: High-density PowerShell script generation rules for minimal token usage
globs: "*.ps1, *.psm1"
---
# PowerShell Token Optimization
- **Use Short Aliases:** Use short aliases instead of full cmdlet names to drastically cut output tokens:
- Use `gc` instead of `Get-Content`
- Use `gci` instead of `Get-ChildItem`
- Use `%` instead of `ForEach-Object`
- Use `?` instead of `Where-Object`
- Use `measure` instead of `Measure-Object`
- **Pipeline Over Loops:** Prefer pipeline chains (`gci | % { ... }`) over multi-line `foreach ($item in $items) { ... }` blocks.
- **No Help/Comments:** Do not generate `.SYNOPSIS`, `.DESCRIPTION`, or comment-based help at the top of scripts.
- **Omit Parameter Names:** Drop explicit parameter names where positional arguments are clear (e.g., use `gc file.txt` instead of `Get-Content -Path file.txt`).
- **Silent Execution:** Do not add verbose logging, `Write-Host`, or `Write-Output` unless explicitly asked to create UI/logs.
- **Preserve CLI Arguments:** Do not duplicate full multi-line `yt-dlp` command-line arguments, format strings, or output templates if they are not the subject of the modifications. Use placeholders or variable references.
+14
View File
@@ -0,0 +1,14 @@
---
description: Ultra-dense Python code generation rules to save output tokens
globs: "*.py"
---
# Python Token Optimization
- **Use Syntactic Sugar:** Prioritize list comprehensions, dict comprehensions, and ternary operators (`x if condition else y`) to keep code on a single line.
- **Built-in Libraries First:** Use standard libraries (`pathlib`, `json`, `subprocess`, `asyncio`) instead of introducing heavy external dependencies unless already in `requirements.txt`.
- **Type Hinting:** Do NOT add type hints (`def func(x: int) -> str:`) unless the existing file strictly uses them. Type hints consume significant tokens.
- **No Format Duplication:** When modifying scripts (like video downloaders), provide only the modified function or class method. Never output the `if __name__ == "__main__":` block or argument parsing logic if they haven't changed.
- **No Docstrings:** Strictly forbid writing `"""docstrings"""` or `# comments` explaining the logic.
- **Preserve yt-dlp Options:** Never rewrite, duplicate, or expand the `ydl_opts` configuration dictionary or custom extraction options. If changes are unrelated to download options, use a placeholder comment like `# ... existing ydl_opts ...` instead of outputting the full dictionary block.
+22
View File
@@ -0,0 +1,22 @@
---
description: Global token-saving rules for ultra-concise communication and minimal context usage
globs: *
---
# Token-Saving Instructions
## Communication Strategy
- **Strictly no fluff:** Omit all greetings, pleasantries, apologies, and concluding remarks.
- **Direct answers only:** Start your response immediately with the solution, code block, or direct answer.
- **Extreme brevity:** Keep explanations under 2-3 sentences. Use bullet points instead of long paragraphs.
## Code Generation Guidelines
- **Do not restate existing code:** Never copy and paste parts of my existing file just to show where to insert changes.
- **Provide diffs only:** Show only the specific lines that need to be changed, added, or deleted. Use brief comments (`// ... existing code ...`) to show placement if necessary.
- **No boilerplate:** Do not generate setup code, imports, or boilerplate unless explicitly requested.
- **Single-line implementations:** Prefer concise, clean, short code syntax where readable (e.g., arrow functions, ternary operators).
## Code Review and Verification
- Do not explain why the code works unless asked.
- If the solution is simple, output *only* the code block and nothing else.
- If you need more information, ask a single, precise question. Do not list multiple hypotheticals.
+44
View File
@@ -0,0 +1,44 @@
---
description: Bump версии в csproj и тег релиза vX.Y.Z при обновлениях
alwaysApply: true
---
# Версия сборки и теги релиза
Единственный источник — `VideoDownloader.App/VideoDownloader.App.csproj`. Синхронно обновляй:
- `Version` (например `0.7.0`)
- `AssemblyVersion` — `x.y.z.0`
- `FileVersion` — `x.y.z.0`
- `InformationalVersion` остаётся `$(Version)`
## Обычные правки (patch)
После осмысленного изменения в `VideoDownloader.App` поднимай **patch** на **+0.0.1** (например `0.7.0` → `0.7.1`). Bump в **том же коммите**, что и изменение, где это уместно.
## Minor / major
При заметном релизе можно поднять **minor** или **major** по смыслу (например `0.6.x` → `0.7.0`). Не обязательно делать это при каждой мелкой правке.
## Тег версии при каждом bump (`vX.Y.Z`) — не забывать
Ветки вида `v0.7.x` **не используем** — маркер релиза — **легковесный или аннотированный тег** с тем же именем.
При **любом** повышении `<Version>` в csproj в том же цикле работы:
1. Закоммитить bump (вместе с изменениями или коммитом `chore: bump version to X.Y.Z`).
2. Создать тег на **текущем** `HEAD` (после коммита с bump):
`git tag -a vX.Y.Z -m "Release X.Y.Z"` (или `git tag vX.Y.Z` для легковесного тега).
Если тег с таким именем уже существовал локально по ошибке: удалить и создать заново на нужном коммите, либо `git tag -f` осознанно.
3. Запушить **обязательно**: **`git push origin native-code`** и **`git push origin vX.Y.Z`** (первый релиз с этим номером).
Повторный push того же тега на другой коммит на сервере обычно запрещён — bump версии делается новым номером и новым тегом.
Именование тега: **`v` + полная semver из `<Version>`** (пример: `v0.7.2`), без префикса `release/`.
**Не пушить** только `native-code` без нового **`v*`** — при bump версии всегда создавай и пушь соответствующий тег.
### Проверка на GitHub
Релиз по тегу: вкладка **Releases** или список тегов в репозитории; `git checkout v0.7.2` локально даёт ту же ревизию, что и при сборке с этим bump.
Если на `origin` ещё есть старая **ветка** с тем же имени, что у тега, удаляй явно: `git push origin --delete refs/heads/vX.Y.Z` (иначе Git может не разрешить неоднозначность `vX.Y.Z`).
+65
View File
@@ -0,0 +1,65 @@
# Çàâèñèìîñòè è ïàêåòû
node_modules/
bower_components/
jspm_packages/
.npm/
vendor/
.pnpm-store/
# Âèðòóàëüíûå îêðóæåíèÿ (Python)
.venv/
venv/
env/
ENV/
.env/
# Ñáîðêà, êýø è àðòåôàêòû
dist/
build/
out/
.next/
.nuxt/
.docusaurus/
.cache/
.sass-cache/
.turbo/
target/
bin/
obj/
# Ñèñòåìíûå ïàïêè IDE è ëîãè
.git/
.svn/
.idea/
.vscode/
.cursor/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Ìåäèà, øðèôòû è òÿæåëûå áèíàðíûå ôàéëû
*.png
*.jpg
*.jpeg
*.gif
*.svg
*.ico
*.mp4
*.mp3
*.pdf
*.zip
*.tar.gz
*.rar
*.exe
*.dll
*.woff
*.woff2
*.ttf
*.eot
# Ñãåíåðèðîâàííûå ôàéëû áëîêèðîâîê (îïöèîíàëüíî, ýêîíîìÿò ìíîãî òîêåíîâ)
package-lock.json
yarn.lock
pnpm-lock.yaml
poetry.lock
-8
View File
@@ -1,8 +0,0 @@
.cursor/
tools/*.log
*.log
*.bak
Logs/
sac-spool/
login_monitor.settings.ps1
exchange_monitor.settings.ps1
+213 -8
View File
@@ -1,11 +1,216 @@
# Развёртывание
# Развёртывание RDP Login Monitor в домене
Документация перенесена в каталог **[Docs/](Docs/README.md)**:
Монитор ставится в **`C:\ProgramData\RDP-login-monitor\`**, задачи планировщика создаёт сам **`Login_Monitor.ps1`** (параметр `-InstallTasks`). Доставку по сети выполняет **`Deploy-LoginMonitor.ps1`**.
| Руководство | Файл |
|-------------|------|
| 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) |
## Файлы на файловой шаре
Оглавление: **[Docs/README.md](Docs/README.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 и/или 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).
## Безопасность и замечания
- ACL на шару: чтение только нужным **компьютерам** / группам; файл **`Login_Monitor.ps1`** может содержать токен — ограничивайте доступ.
- DPAPI-секреты привязаны к машине: шифровать на каждой цели (Telegram token/chat id, пароль SMTP) или использовать plain на закрытой шаре (см. комментарии в **`Login_Monitor.ps1`**, helper **`Encrypt-DpapiForRdpMonitor.ps1`**).
- Deploy при ошибках пишет в **`deploy.log`** и завершается с кодом **0**, чтобы не блокировать загрузку ОС; проблемы смотрите по логу на ПК.
-167
View File
@@ -1,167 +0,0 @@
<#
.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
+18 -1434
View File
File diff suppressed because it is too large Load Diff
-447
View File
@@ -1,447 +0,0 @@
<#
.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
-13
View File
@@ -1,13 +0,0 @@
# Документация 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)**.
-81
View File
@@ -1,81 +0,0 @@
# Публикация пакета на 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** в рабочих строках — их можно запускать и без перепубликации.
-237
View File
@@ -1,237 +0,0 @@
# Развёртывание 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`**.
-268
View File
@@ -1,268 +0,0 @@
# 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
+2 -3
View File
@@ -1,6 +1,5 @@
# Запуск: от администратора на ТОМ ЖЕ компьютере, где будет Login_Monitor.ps1.
# Результат (Base64) вставьте в login_monitor.settings.ps1 или exchange_monitor.settings.ps1:
# $TelegramBotTokenProtectedB64 / $TelegramChatIDProtectedB64 / $MailSmtpPasswordProtectedB64.
# Запуск: от администратора на ТОМ ЖЕ компьютере, где будет Login_Monitor.ps1.
# Результат (Base64) вставьте в $TelegramBotTokenProtectedB64 / $TelegramChatIDProtectedB64 / $MailSmtpPasswordProtectedB64.
param(
[Parameter(Mandatory = $true)][string]$PlainText
)
-935
View File
@@ -1,935 +0,0 @@
<#
.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
+2 -2
View File
@@ -1,8 +1,8 @@
#Requires -RunAsAdministrator
#Requires -RunAsAdministrator
[CmdletBinding()]
param(
[string]$TaskName = "RDP-Login-Monitor-Deploy",
[string]$DeployScriptPath = "\\dc.contoso.local\NETLOGON\RDP-login-monitor\Deploy-LoginMonitor.ps1",
[string]$DeployScriptPath = "\\B26\NETLOGON\RDP-login-monitor\Deploy-LoginMonitor.ps1",
[ValidateRange(5, 1440)][int]$RepeatMinutes = 60,
[switch]$RunNow
)
-51
View File
@@ -1,51 +0,0 @@
<#
.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
+86
View File
@@ -0,0 +1,86 @@
#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
+291 -3391
View File
File diff suppressed because it is too large Load Diff
-214
View File
@@ -1,214 +0,0 @@
<#
.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
}
+127 -44
View File
@@ -1,62 +1,145 @@
# RDP Login Monitor
**Версия:** `2.1.14-SAC` (`$ScriptVersion` + `version.txt`)
PowerShell-набор для мониторинга входов в Windows с уведомлениями в Telegram и/или Email (SMTP).
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`**, на **КД, где запущен монитор** (имя совпадает с **`$LockoutMonitorDomainController`**) — **`4740`** (блокировка УЗ + IP из IIS ActiveSync), **ежедневный отчёт** (активные сессии через `quser`), **heartbeat**, **ротация логов**, уведомления в Telegram и/или Email.
- Установка задач: запуск **`Login_Monitor.ps1 -InstallTasks`** создаёт:
- `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)**.
- **`Encrypt-DpapiForRdpMonitor.ps1`** — опционально для подготовки DPAPI-строк токена/chat id и пароля SMTP (`$MailSmtpPasswordProtectedB64`).
## Что изменилось (важное)
- **Кодировка `.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` и задайте каналы оповещений:
- **Telegram:** `$TelegramBotToken` / `$TelegramChatID` или `...ProtectedB64`
- **Email (SMTP):** `$MailSmtpHost`, `$MailFrom`, `$MailTo`, `$MailSmtpPort` (по умолчанию 587), при необходимости `$MailSmtpUser` / `$MailSmtpPassword` (или `$MailSmtpPasswordProtectedB64` через DPAPI), `$MailSmtpStartTls` / `$MailSmtpSsl`
- **Порядок:** `$NotifyOrder` — пусто = авто (Telegram → Email, только настроенные); иначе `telegram,email` или `email` и т.п. (допускаются `tg`, `mail`)
4. Запускайте с правами администратора (чтение `Security` журнала и регистрация задач).
5. Логи и служебные файлы будут в:
- `C:\ProgramData\RDP-login-monitor\Logs\`
6. (Опционально) Подавление части алертов по списку — см. раздел **«7) ignore.lst»** ниже.
7. (Опционально) Мониторинг блокировок AD на КД — **`$LockoutMonitorDomainController`** (короткое имя узла, на котором **установлен и запущен** монитор), **`$NetBiosDomainName`**, **`$ExchangeIisLogPath`**, **`$ExchangeIisLogMinutesBeforeLockout`** (по умолчанию 30), **`$ExchangeIisLogTailLines`** (по умолчанию 5000), **`$ExchangeServerHostForIisExclude`**. В оповещении: пользователь из 4740 и IP из IIS за окно до блокировки. В **`ignore.lst`** префикс **`4740:`** или **`all:`** — см. **`ignore.lst.example`**.
8. Heartbeat: при отсутствии обновления **`Logs\last_heartbeat.txt`** дольше **`$HeartbeatStaleAlertMultiplier` × `$HeartbeatInterval`** (по умолчанию 2×1 ч) — оповещение в Telegram/Email.
## 2) Ручной запуск
Используйте этот вариант для быстрой проверки старта/логики без установки задач планировщика.
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File "\\<DC>\NETLOGON\RDP-login-monitor\Deploy-LoginMonitor.ps1"
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\RDP-login-monitor\Login_Monitor.ps1"
```
Каталог: `C:\ProgramData\RDP-login-monitor\` · настройки: `login_monitor.settings.ps1` (не перезаписываются при деплое).
Примечание: при ручном запуске монитор работает в текущей сессии до остановки (например, `Ctrl+C`).
## Обновление через SAC (WinRM)
## 3) Запуск через Планировщик заданий (Task Scheduler)
С карточки хоста в SAC: **«Обновить через WinRM»** (SAC ≥ 0.20.15). Сервер тянет репозиторий с git, отдаёт zip по HTTPS; на ПК **не нужны** git и NETLOGON.
Текущая схема: вручную задачи в GUI создавать не нужно.
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
Get-Content "C:\ProgramData\RDP-login-monitor\Logs\login_monitor.log" -Tail 60
Login_Monitor.ps1 -CheckSac
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\RDP-login-monitor\Login_Monitor.ps1" -InstallTasks
```
## Документация
Скрипт сам зарегистрирует `RDP-Login-Monitor` и `RDP-Login-Monitor-Watchdog`, а также запросит немедленный первый запуск задач.
- [Docs/README.md](Docs/README.md) — развёртывание, Exchange
- [security-alert-center](https://git.kalinamall.ru/PapaTramp/security-alert-center) — сервер, RDG flap, qwinsta через SAC UI/Seaca
Для доменной установки/обновления с шары вручную ничего в планировщике на клиенте настраивать не требуется: используйте `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`) уходит сводка по **`quser`** (Telegram/Email); метка последнего отчёта — `Logs\last_daily_report.txt`.
- Stale heartbeat: если **`last_heartbeat.txt`** не обновлялся дольше **`$HeartbeatStaleAlertMultiplier` × `$HeartbeatInterval`** — оповещение в Telegram/Email (см. п. 8 подготовки).
- При старте в Telegram/Email: строка **«Каналы уведомлений»** (фактический порядок доставки), плюс режим RDS/4740 по конфигурации.
- Telegram при старте: при установленном **RD Session Host** (или аналогичных компонентах RDS, не только шлюз) — строка про входы по RDP/RDS на этом сервере; при доступном журнале **RD Gateway** — отдельная строка про подключения к **внутренним целевым ПК** через шлюз (302/303). Узел только с ролью RD Gateway не дублирует формулировку «хост сессий».
## 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`**). По умолчанию правила относятся к **`4624`/`4625`**; префикс **`4740:`** (или **`lockout:`**, **`блокир:`**) — только к блокировкам учётной записи; **`all:`** — и входы, и **4740**. Для **4740** тип **`ip:`** сравнивается с IP из IIS ActiveSync. Жёсткие исключения в скрипте по-прежнему для всех типов событий, кроме **4740** (там только `ignore.lst` и встроенные проверки пользователя).
События **RD Gateway (`302`/`303`)**, **RCM `1149`**, ежедневный отчёт и heartbeat **этим файлом не настраиваются**.
### Как читается файл
- Чтение выполняется по мере обработки событий; содержимое **кэшируется в памяти**. Если **`LastWriteTimeUtc`** файла изменился (редактирование и сохранение), список **перечитывается автоматически** — перезапуск монитора не обязателен.
- Кодировка: **UTF-8** (`Get-Content -Encoding UTF8`). Строка может начинаться с BOM — он отбрасывается при разборе.
- Пустые строки пропускаются. Строки, начинающиеся с **`#`** или **`;`**, считаются комментариями.
- Строка с **`:`**: берётся **первая** двоеточие — всё слева (после обрезки пробелов) определяет тип правила, всё справа — значение. Если справа пусто, строка игнорируется.
- Строка **без** **`:`**: целиком трактуется как правило типа «любое совпадение» (см. ниже).
### Префикс области (в самом начале строки, до типа правила)
| Префикс | События |
| --- | --- |
| *(нет)* | **4624**, **4625** |
| `4740:`, `lockout:`, `блокир:` | **4740** |
| `all:`, `*:` | **4624**, **4625**, **4740** |
Пример: `4740:user:svc_sync` — не слать оповещение о блокировке этой УЗ.
### Типы правил (левая часть до первого `:` после префикса области)
| Левая часть (фрагменты совпадают как regex, без учёта регистра) | Поле события |
| --- | --- |
| `рабоч`, `workstation`, `wks` | имя рабочей станции (**WorkstationName** и аналоги в XML события) |
| `польз`, `username`, `subject`, `account`, `target user`, целое слово `user` | имя пользователя (**TargetUserName** и др.) |
| `ip`, `ip адрес`, `ipaddress`, `адрес ip` | IP источника (**IpAddress** и др.), только если в событии есть непустой IP |
Если левая часть **не** подошла ни к одному типу, но двоеточие есть — используется режим как в разборе строк Telegram: тип **«любое»**, значение — **только правая часть** (метка слева отбрасывается).
### Совпадение для типа «любое» (строка без `:` или «неизвестная» метка слева от `:`)
Проверка по очереди:
1. Если значение похоже на **IPv4** — сравнивается с IP источника в событии (точное совпадение, без учёта регистра для текста не применимо).
2. Если значение содержит **`\`** — сравнивается с **учётной записью**: полное совпадение с `DOMAIN\user` **или** совпадение с **SAM** после последнего `\` (как `DOMAIN\IVANOV` при правиле `IVANOV`).
3. Иначе сначала полное совпадение с **именем рабочей станции**, затем с **учётной записью** по тем же правилам, что в п.2.
Для явных типов **User** / **Workstation** / **Ip** используется только соответствующее поле (для пользователя — те же правила полного имени и SAM, что в п.2).
### Примеры и поставка
- Расширенные примеры строк — в **`ignore.lst.example`** в корне репозитория (скопируйте на сервер как **`ignore.lst`** и отредактируйте).
- **`Deploy-LoginMonitor.ps1`** этот файл **не копирует**: правила обычно разные на каждой машине; при необходимости создайте `ignore.lst` вручную или через вашу систему конфигурации.
## Ключевые слова (для поиска репозитория)
`rdp`, `rd-gateway`, `rdp-gateway`, `rds`, `remote-desktop`, `windows-security-log`, `eventlog`, `event-id-4624`, `event-id-4625`, `event-id-4740`, `event-id-302`, `event-id-303`, `account-lockout`, `active-sync`, `exchange`, `iis`, `smtp`, `email`, `powershell`, `telegram-bot`, `watchdog`, `gpo`, `netlogon`, `domain-deployment`, `windows-server`, `monitoring`
## English
See repository docs; agent contract is in SAC `docs/agent-integration.md`.
+11 -18
View File
@@ -1,29 +1,24 @@
# RDP Login Monitor
**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), **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.
- 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, 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 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).
- Full deploy/GPO guidance: **[DEPLOY.md](DEPLOY.md)**.
- **`Encrypt-DpapiForRdpMonitor.ps1`** — optional helper to prepare DPAPI-protected Base64 for the bot token / chat id and SMTP password (`$MailSmtpPasswordProtectedB64`).
## 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 **`Успех и сбой`** 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)**.
- **`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.
## 1) Preparation
@@ -31,9 +26,8 @@ PowerShell toolkit for monitoring Windows logons with Telegram and/or Email (SMT
- `C:\ProgramData\RDP-login-monitor\`
2. Copy at least:
- `Login_Monitor.ps1`
- `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):
- (for domain rollout on a share) `Deploy-LoginMonitor.ps1` and `version.txt`.
3. Edit `Login_Monitor.ps1` and configure notification channels:
- **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)
@@ -41,7 +35,7 @@ PowerShell toolkit for monitoring Windows logons with Telegram and/or Email (SMT
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`**.
7. (Optional) AD account lockout monitoring on a DC — **`$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
@@ -66,7 +60,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 [Docs/deploy-rdp-login-monitor.md](Docs/deploy-rdp-login-monitor.md)).
For domain deployment from a share you do not configure the scheduler on clients by hand — use `Deploy-LoginMonitor.ps1` (see `DEPLOY.md`).
## 4) Post-install checks
@@ -92,9 +86,8 @@ 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 is built into **`Login_Monitor.ps1`** (`-Watchdog`); scheduled tasks **`RDP-Login-Monitor`** / **`RDP-Login-Monitor-Watchdog`** are registered by **`-InstallTasks`**.
- **`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`**.
- **`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`
@@ -143,7 +136,7 @@ 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 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.
- **`Deploy-LoginMonitor.ps1`** does **not** copy this file — rules are usually host-specific; create **`ignore.lst`** manually or via your configuration tooling.
## Keywords (for discovery)
-184
View File
@@ -1,184 +0,0 @@
<#
.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())
}
-29
View File
@@ -1,29 +0,0 @@
<#
.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
View File
File diff suppressed because it is too large Load Diff
+117
View File
@@ -0,0 +1,117 @@
<#
.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 свежий."
}
-54
View File
@@ -1,54 +0,0 @@
<#
.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'
# )
+1 -15
View File
@@ -6,10 +6,7 @@
# Область действия (префикс в начале строки, необязателен):
# (по умолчанию) — только 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
# all: — и 4624/4625, и 4740
#
# Форматы правила (после префикса области):
# user:domain\user
@@ -29,16 +26,5 @@
# 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
-93
View File
@@ -1,93 +0,0 @@
<#
.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
-39
View File
@@ -1,39 +0,0 @@
# 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)"
-41
View File
@@ -1,41 +0,0 @@
# 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"
-67
View File
@@ -1,67 +0,0 @@
# 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)"
-62
View File
@@ -1,62 +0,0 @@
# 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"
-169
View File
@@ -1,169 +0,0 @@
# 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'
-47
View File
@@ -1,47 +0,0 @@
# 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)"
-46
View File
@@ -1,46 +0,0 @@
<#
.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
-51
View File
@@ -1,51 +0,0 @@
<#
.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'"
-8
View File
@@ -1,8 +0,0 @@
$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'
-55
View File
@@ -1,55 +0,0 @@
# Проверка парсинга 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).'
-8
View File
@@ -1,8 +0,0 @@
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'
-33
View File
@@ -1,33 +0,0 @@
. (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'
}
-22
View File
@@ -1,22 +0,0 @@
. (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)
}
}
}
-49
View File
@@ -1,49 +0,0 @@
. (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'
}
-75
View File
@@ -1,75 +0,0 @@
. (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
}
}
-15
View File
@@ -1,15 +0,0 @@
. (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'
}
-16
View File
@@ -1,16 +0,0 @@
# 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
-39
View File
@@ -1,39 +0,0 @@
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
}
}
-240
View File
@@ -1,240 +0,0 @@
<#
.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
View File
@@ -1 +1 @@
2.1.15-SAC
1.5.3