Compare commits

...

31 Commits

Author SHA1 Message Date
PTah f9db03a01b fix: heartbeat only to SAC, never Telegram/email
agent.heartbeat in Send-NotifyOrSac skips local channels in dual/fallback/off.
Bump to 1.2.6-SAC.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 07:56:18 +10:00
PTah 9893e9c599 chore: bump release to 1.2.5-SAC
Raise ScriptVersion/version.txt for Sac-Client payload product_version and
update README release reference to keep docs aligned.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 15:53:29 +10:00
PTah 6774ff77d1 feat: bootstrap SAC settings on deploy when upgrading from pre-SAC
Deploy overwrites login_monitor.settings.ps1 from the trusted example when
Sac-Client.ps1 or SacApiKey/UseSAC are missing, with a .bak backup. Skips
overwrite when SAC is already configured; continues deploy on version match
if SAC bootstrap is still required.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 15:23:32 +10:00
PTah 17c14fa5bc fix: do not stop monitor when SAC /health is temporarily down
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 15:02:07 +10:00
PTah ecceb70cc7 fix: deploy graceful recycle writes restart.request directly (no 90s wait)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 14:53:54 +10:00
PTah cbcf87ca2a chore: bump version to 1.2.2-SAC
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 14:44:02 +10:00
PTah 3e0f556f6d feat: graceful restart without Stop-Process; SAC key in settings example
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 14:43:04 +10:00
PTah 8a10e471db docs: Windows/SAC update checklist; default Telegram in settings example
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 14:27:27 +10:00
PTah d3d836432b feat: SAC client 1.2.0-SAC and daily report details for ingest
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 14:15:16 +10:00
PTah 77bec04cfe feat(Login): вынести локальные настройки в login_monitor.settings.ps1
Секреты, SMTP, 4740 и IP-исключения больше не в Login_Monitor.ps1 —
не слетают при автообновлении с NETLOGON. Deploy создаёт settings из example при первой установке.
2026-05-25 09:44:43 +10:00
PTah 9ea647d905 fix(Exchange): stop Import-ExchangeManagementShell leaking True (v1.6.7)
return True from EMS import was collected into mailbox lists and domain joins, causing Get-InboxRule on mailbox True. Suppress import output at call sites and log accepted domains via @() enumeration.
2026-05-22 16:10:36 +10:00
PTah 96d9c88a4a fix(Exchange): define Get-ExchangeInboxScanScopeLabel before InstallTasks (v1.6.6) 2026-05-22 16:02:05 +10:00
PTah 423fa49d35 feat(Exchange): skip corrupt inbox rule stores (v1.6.5)
Add SkipInboxScanMailboxes with k.selezneva@kalinamall.ru by default. Filter inbox scan list, log SKIP, and catch Get-InboxRule failures to avoid Watson aborting full scans.
2026-05-22 16:00:09 +10:00
PTah 407ddf2454 fix(deploy): copy exchange_monitor.settings.example to ProgramData (v1.6.4)
Example settings were published to NETLOGON but omitted from Deploy-DomainMonitors file list, so Copy-Item from ProgramData failed on fresh installs.
2026-05-22 15:00:30 +10:00
PTah e90beae974 fix: Exchange monitor encoding for PS 5.1 (v1.6.3)
ASCII runtime strings in Exchange-MailSecurity and Notify-Common avoid parser errors when scripts are copied without UTF-8 BOM. Deploy copies .ps1 to ProgramData with UTF-8 BOM via Copy-ScriptFileWithUtf8Bom.
2026-05-22 14:57:35 +10:00
PTah 0c7c2254cf fix: Deploy-DomainMonitors encoding for PS 5.1 / NETLOGON (v1.6.2)
- ASCII runtime strings in domain deploy/install scripts
- Publish *.ps1 to NETLOGON with UTF-8 BOM
2026-05-22 14:53:28 +10:00
PTah f41effc845 feat(Exchange): VIP scan, disabled rules, first-baseline suppress (v1.6.1)
- VipMailboxes + VipMailboxPatterns; unified VIP scope
- Scan disabled inbox rules with external forward (lower severity)
- Suppress per-finding alerts on first baseline run
- Post-scan summary and install notification
2026-05-22 14:44:57 +10:00
PTah 1eaa9b6d21 docs: add Docs/ guides; Exchange Mail Security scripts (v1.6.0)
- Docs: RDP deploy, Exchange mail security, NETLOGON publish
- Exchange-MailSecurity.ps1, Notify-Common, Deploy/Install-DomainMonitors
- DEPLOY.md redirects to Docs/
2026-05-22 14:32:50 +10:00
PTah ea26f1d8b7 feat: aggregated 4625 burst alerts (two-tier rate limit, v1.5.10)
- Tier 1: 5 failures per 60s per IP+user
- Tier 2: 12 failures per 60s per IP (spraying)
- Suppress per-event 4625 during burst; no auto-ban
2026-05-22 14:13:06 +10:00
PTah f89b7f5b2f chore: clarify IIS log line wording for 4740 (v1.5.9) 2026-05-22 11:06:19 +10:00
PTah b3189cbf01 config: LockoutMonitorDomainController K6A-DC3 (v1.5.8) 2026-05-22 11:01:38 +10:00
PTah 813b2cda99 fix: daily report uses full path to quser.exe when System32 missing from PATH (v1.5.7) 2026-05-22 10:54:57 +10:00
PTah b1fa6ec9f1 fix: invoke auditpol by full path when System32 is missing from PATH (v1.5.6) 2026-05-22 10:41:59 +10:00
PTah 844d1ce520 Fixed BOM 2026-05-22 10:28:40 +10:00
PTah dfcc0fc3fd config: fifth IIS UNC, NETLOGON publish script (v1.5.5) 2026-05-22 10:13:54 +10:00
PTah 06c2b6cbf7 config: IIS logs on fifth UNC path (v1.5.5) 2026-05-22 10:05:18 +10:00
PTah 0c683c796c config: Kalina Telegram, DC3/B26 lockout, IIS path (v1.5.4) 2026-05-22 09:58:09 +10:00
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
28 changed files with 4217 additions and 322 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 -192
View File
@@ -1,195 +1,11 @@
# Развёртывание RDP Login Monitor в домене # Развёртывание
Монитор ставится в **`C:\ProgramData\RDP-login-monitor\`**, задачи планировщика создаёт сам **`Login_Monitor.ps1`** (параметр `-InstallTasks`). Доставку по сети выполняет **`Deploy-LoginMonitor.ps1`**. Документация перенесена в каталог **[Docs/](Docs/README.md)**:
## Файлы на файловой шаре | Руководство | Файл |
|-------------|------|
| RDP Login Monitor (NETLOGON, GPO) | [Docs/deploy-rdp-login-monitor.md](Docs/deploy-rdp-login-monitor.md) |
| Exchange Mail Security | [Docs/exchange-mail-security.md](Docs/exchange-mail-security.md) |
| Публикация на шару | [Docs/deploy-netlogon-publish.md](Docs/deploy-netlogon-publish.md) |
Создайте каталог, доступный **конечным компьютерам** на чтение (часто учётная запись компьютера домена), например: Оглавление: **[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** отдельные события **Security `4624`/`4625`** (например, шум от известной рабочей станции, тестового пользователя или фиксированного IP).
- **`Deploy-LoginMonitor.ps1` с шары `ignore.lst` не доставляет** — при необходимости создавайте файл локально или копируйте своим способом.
- Примеры синтаксиса — в репозитории в файле **`ignore.lst.example`**.
## Задачи планировщика после `-InstallTasks`
Параметр **`Login_Monitor.ps1 -InstallTasks`** создаёт две задачи:
| Имя | Назначение |
|-----|------------|
| **`RDP-Login-Monitor`** | Запуск основного монитора при старте ОС (`Register-ScheduledTask`, триггер «При запуске компьютера»). |
| **`RDP-Login-Monitor-Watchdog`** | Тот же файл **`Login_Monitor.ps1`** с аргументом **`-Watchdog`**: короткая проверка «жив ли монитор», при необходимости поднимает процесс. |
Watchdog регистрируется через **`schtasks.exe /Create /SC MINUTE /MO 5`** (а не через CIM-триггеры PowerShell): на части ОС у объектов триггера нет настраиваемых **`RepetitionInterval`** / длительность «раз и навсегда» режется планировщиком — из‑за этого раньше вторая задача могла не создаваться.
Перед созданием выполняется **`schtasks /Delete … /F`** (если задачи ещё не было, сообщение об ошибке подавляется — это нормально).
Сразу после регистрации задач вызывается **`schtasks /Run`** и для **основной задачи**, и для **watchdog**: первый запуск не ждёт перезагрузку и ближайшее 5‑минутное окно.
Если watchdog срабатывает, а основной монитор «не поднимается», смотрите **`Logs\watchdog.log`** (сообщение о PID и предупреждение, если процесс сразу завершился) и **конец `Logs\login_monitor.log`** — частая причина (исправлено в **1.3.5+**): пустой **`$PSCommandPath`** у процесса, запущенного через **`Start-Process`**; скрипт теперь подставляет путь через **`$PSScriptRoot`**.
Один экземпляр монитора фиксируется **файлом блокировки** в **`C:\ProgramData\RDP-login-monitor\.login_monitor_single_instance.lock`** (не Global mutex): так и **SYSTEM** (задача планировщика), и **интерактивный администратор** могут корректно запускать скрипт вручную без «Отказано в доступе» к mutex (версия **1.3.6+**).
Проверка:
```powershell
Get-ScheduledTask -TaskName 'RDP-Login-Monitor','RDP-Login-Monitor-Watchdog' -ErrorAction SilentlyContinue
schtasks /Query /TN "RDP-Login-Monitor-Watchdog"
```
Логи: **`...\Logs\login_monitor.log`**, **`...\Logs\watchdog.log`**.
## Запуск Deploy и политики с файловой шары
Если при запуске **`Deploy-LoginMonitor.ps1`** или **`Login_Monitor.ps1`** с UNC по **FQDN** (`\\dc.domain.local\...`) PowerShell сообщает про **цифровую подпись** / политику выполнения, чаще всего помогает путь по **короткому имени** контроллера: **`\\DC01\NETLOGON\...`** (клиент относит UNC к интрасети иначе). Параллельно убедитесь, что команда реально с **`powershell.exe -ExecutionPolicy Bypass -File "..."`**.
## Версии: `version.txt` и `$ScriptVersion`
| Что | Роль |
|-----|------|
| **`version.txt` на шаре** | Единственный источник для **`Deploy-LoginMonitor.ps1`**: решение «класть ли новый файл на компьютер». Поднимайте номер **всякий раз**, когда нужно, чтобы доменные машины забрали **новую копию** скрипта с шары — в том числе при мелких правках **без** изменения «видимой» версии в логах. |
| **`$ScriptVersion` в `Login_Monitor.ps1`** | Версия для **логов и Telegram** (что видит администратор). Меняйте при **значимых** релизах; для полной ясности можно держать ту же строку, что и в **`version.txt`**. |
**Типичные сценарии:**
- Крупный релиз: обновили **`Login_Monitor.ps1`**, подняли **`$ScriptVersion`** (например `1.4.0`) и записали то же в **`version.txt`** на шаре.
- Мелкая правка на шаре (опечатка, узкий фикс), не хотите путать отчёты по версии в логах: поднимите только **`version.txt`** (например с `1.3.0` на **`1.3.0.1`** — поддерживаются четырёхкомпонентные номера .NET **Version**). **`$ScriptVersion`** можно не трогать; на клиентах после деплоя в логах по-прежнему будет старая «человеческая» версия, но файл будет актуальным.
- Идеально поддерживать синхронность **`version.txt`** и **`$ScriptVersion`**, когда правки крупные и версия в логах должна совпадать с дистрибутивом.
Итого: **обновляться «по сети» обязан именно `version.txt`**; строка **`$ScriptVersion`** нужна для прозрачности в мониторинге и может совпадать с шарой или отставать по «маркетингу», если вы сознательно крутите только patch в **`version.txt`**.
## Групповая политика (GPO): проверенная схема
Ниже шаги, которые были проверены на практике для запуска деплоя при старте компьютера.
1. Положите три файла на шару (см. выше), например:
`\\B26\NETLOGON\RDP-login-monitor\`
2. Создайте/настройте GPO и привяжите её к OU с **компьютерами** (не пользователями).
3. В GPO откройте:
**Конфигурация компьютера****Политики****Конфигурация Windows****Сценарии (запуск/завершение)****Автозагрузка**.
4. На вкладке **«Сценарии PowerShell»** добавьте скрипт:
`\\B26\NETLOGON\RDP-login-monitor\Deploy-LoginMonitor.ps1`
Параметры можно оставить пустыми, если рядом на шаре лежат `Login_Monitor.ps1` и `version.txt`.
5. Security Filtering:
- можно убрать `Authenticated Users`,
- добавить группу компьютеров (например `B26\RDP-Login`),
- убедиться, что у неё есть права **Read** + **Apply group policy**.
6. Проверьте, что в группе действительно состоит объект компьютера, например `FVG-PC$`.
7. Проверьте доступ на шару/NTFS для контекста компьютера (обычно через `Domain Computers` или целевую группу), потому что startup-скрипт выполняется от имени **SYSTEM**.
### Важные нюансы
- Кнопка **«Показать файлы»** в свойствах Startup показывает папку GPO в `SYSVOL` (`...\Policies\{GUID}\Machine\Scripts\Startup`).
- Если вы указали в настройке сценария **UNC путь в NETLOGON**, ваш `.ps1` в этой папке `SYSVOL` не появится — это нормально.
- После изменения membership компьютера в security-группе часто требуется **перезагрузка** (не только `gpupdate /force`), чтобы обновился токен компьютера.
### Как запускать без ожидания перезагрузки
- Startup-сценарий GPO штатно отрабатывает при загрузке ОС, но сам `Deploy-LoginMonitor.ps1` после `-InstallTasks` теперь инициирует немедленный `schtasks /Run`:
- `RDP-Login-Monitor`
- `RDP-Login-Monitor-Watchdog`
- Поэтому после фактического запуска deploy монитор и watchdog стартуют сразу.
### Серверы без перезагрузок: периодический Deploy
Если сервер перезагружается редко, одного Startup-сценария недостаточно для своевременного обновления. В этом случае добавьте отдельную задачу, которая периодически запускает `Deploy-LoginMonitor.ps1` с шары.
Если серверов немного, можно обойтись без отдельной задачи: при выпуске новой версии выполнять `Deploy-LoginMonitor.ps1` вручную на нужных серверах.
Готовый скрипт в репозитории:
- `Install-DeployScheduledTask.ps1`
Этот вариант удобен для большого парка серверов, когда обновления нужно подтягивать автоматически и без ручного обхода.
Пример (каждые 60 минут и сразу запустить):
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\Install-DeployScheduledTask.ps1" `
-TaskName "RDP-Login-Monitor-Deploy" `
-DeployScriptPath "\\B26\NETLOGON\RDP-login-monitor\Deploy-LoginMonitor.ps1" `
-RepeatMinutes 60 `
-RunNow
```
Проверка:
```powershell
schtasks /Query /TN "RDP-Login-Monitor-Deploy" /V /FO LIST
```
Важно: эта задача отвечает только за доставку обновлений (через `version.txt`). За «живость» процесса по-прежнему отвечает `RDP-Login-Monitor-Watchdog`.
Пример ручного запуска (для небольшого количества серверов):
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "\\B26\NETLOGON\RDP-login-monitor\Deploy-LoginMonitor.ps1"
```
### Диагностика GPO/Startup
- События применения политики и startup-сценариев:
- `Event Viewer -> Applications and Services Logs -> Microsoft -> Windows -> GroupPolicy -> Operational`
- Логи на клиенте:
- `C:\ProgramData\RDP-login-monitor\Logs\deploy.log`
- `C:\ProgramData\RDP-login-monitor\Logs\login_monitor.log`
- `C:\ProgramData\RDP-login-monitor\Logs\watchdog.log`
- Быстрая ручная проверка deploy:
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "\\B26\NETLOGON\RDP-login-monitor\Deploy-LoginMonitor.ps1"
```
## Ручная проверка
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "\\...\Deploy-LoginMonitor.ps1"
```
- **`-WhatIf`** — только сообщение в лог, без копирования.
- **`-SkipStartMonitorAfterUpdate`** — после обновления не запускать процесс монитора (остаются задачи планировщика и следующая загрузка / watchdog).
## Безопасность и замечания
- ACL на шару: чтение только нужным **компьютерам** / группам; файл **`Login_Monitor.ps1`** может содержать токен — ограничивайте доступ.
- DPAPI-секреты привязаны к машине: шифровать на каждой цели или использовать plain на закрытой шаре (см. комментарии в **`Login_Monitor.ps1`**).
- Deploy при ошибках пишет в **`deploy.log`** и завершается с кодом **0**, чтобы не блокировать загрузку ОС; проблемы смотрите по логу на ПК.
+167
View File
@@ -0,0 +1,167 @@
<#
.SYNOPSIS
Deploy domain monitor scripts (Exchange / AD) from share to ProgramData.
.PARAMETER Target
Exchange - Exchange-MailSecurity.ps1 + Notify-Common.ps1; Ad - reserved.
#>
[CmdletBinding()]
param(
[ValidateSet('Exchange', 'Ad')]
[Parameter(Mandatory = $true)]
[string]$Target,
[string]$SourceShareRoot = '',
[switch]$WhatIf,
[switch]$SkipInstallTasks,
[switch]$AllowDowngrade
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$InstallRoot = [System.IO.Path]::GetFullPath("$env:ProgramData\RDP-login-monitor")
$VersionStampPath = Join-Path $InstallRoot 'deployed_domain_monitors_version.txt'
$DeployLogPath = Join-Path $InstallRoot 'Logs\deploy_domain_monitors.log'
$VersionFileName = 'version.txt'
$Utf8Bom = New-Object System.Text.UTF8Encoding $true
$PsExe = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe"
$ExchangeFiles = @(
'Exchange-MailSecurity.ps1',
'Notify-Common.ps1',
'Install-DomainMonitors.ps1',
'exchange_monitor.settings.example.ps1'
)
function Write-DeployLog {
param([string]$Message)
$line = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') - [$Target] $Message" + [Environment]::NewLine
try {
$dir = Split-Path $DeployLogPath -Parent
if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
[System.IO.File]::AppendAllText($DeployLogPath, $line, $Utf8Bom)
} catch { }
Write-Host $line.TrimEnd("`r`n")
}
function Write-TextFileUtf8Bom {
param([string]$Path, [string]$Text)
[System.IO.File]::WriteAllText($Path, $Text, $Utf8Bom)
}
function Copy-ScriptFileWithUtf8Bom {
param(
[Parameter(Mandatory = $true)][string]$SourcePath,
[Parameter(Mandatory = $true)][string]$DestPath
)
$raw = [System.IO.File]::ReadAllBytes($SourcePath)
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
$text = $utf8NoBom.GetString($raw)
if ($text.Length -gt 0 -and [int][char]$text[0] -eq 0xFEFF) {
$text = $text.Substring(1)
}
[System.IO.File]::WriteAllText($DestPath, $text, $Utf8Bom)
}
function Resolve-SourceShareRoot {
if (-not [string]::IsNullOrWhiteSpace($SourceShareRoot)) {
return [System.IO.Path]::GetFullPath($SourceShareRoot.TrimEnd('\'))
}
$here = $PSCommandPath
if ([string]::IsNullOrWhiteSpace($here)) { $here = $MyInvocation.MyCommand.Path }
if ([string]::IsNullOrWhiteSpace($here)) {
throw 'Specify -SourceShareRoot or run this script by full UNC path to the .ps1 file on the share.'
}
return [System.IO.Path]::GetFullPath((Split-Path -Parent $here))
}
function Read-VersionLineFromFile {
param([string]$Path)
if (-not (Test-Path -LiteralPath $Path)) { return $null }
$raw = (Get-Content -LiteralPath $Path -TotalCount 3 -ErrorAction Stop | Where-Object { $_ -match '\S' }) | Select-Object -First 1
if ($null -eq $raw) { return $null }
return ([string]$raw).Trim() -replace '^v', ''
}
function Compare-SemVerLike {
param([string]$A, [string]$B)
try {
$va = [version]$A
$vb = [version]$B
return $va.CompareTo($vb)
} catch {
return [string]::Compare($A, $B, [StringComparison]::OrdinalIgnoreCase)
}
}
$shareRoot = Resolve-SourceShareRoot
$filesToCopy = switch ($Target) {
'Exchange' { $ExchangeFiles }
'Ad' { throw 'Target Ad is not supported yet.' }
}
$sourceVersionFile = Join-Path $shareRoot $VersionFileName
if (-not (Test-Path -LiteralPath $sourceVersionFile)) {
Write-DeployLog "ERROR: missing on share: $sourceVersionFile"
exit 1
}
$shareVer = Read-VersionLineFromFile -Path $sourceVersionFile
$localVer = Read-VersionLineFromFile -Path $VersionStampPath
Write-DeployLog "Share version=$shareVer local stamp=$localVer"
if (-not [string]::IsNullOrWhiteSpace($localVer)) {
$cmp = Compare-SemVerLike -A $shareVer -B $localVer
if ($cmp -eq 0) {
Write-DeployLog 'Version match - skip copy.'
if (-not $SkipInstallTasks) {
Write-DeployLog 'Running InstallTasks check...'
if (-not $WhatIf) {
& $PsExe -NoProfile -ExecutionPolicy Bypass -File (Join-Path $InstallRoot 'Install-DomainMonitors.ps1') -Target $Target
}
}
exit 0
}
if ($cmp -lt 0 -and -not $AllowDowngrade) {
Write-DeployLog 'Share version older than local - skip (use -AllowDowngrade to force).'
exit 0
}
}
if (-not (Test-Path -LiteralPath $InstallRoot)) {
if ($WhatIf) { Write-DeployLog "WhatIf: create $InstallRoot"; exit 0 }
New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null
}
foreach ($name in $filesToCopy) {
$src = Join-Path $shareRoot $name
$dst = Join-Path $InstallRoot $name
if (-not (Test-Path -LiteralPath $src)) {
Write-DeployLog "ERROR: missing on share: $name"
exit 1
}
if ($WhatIf) {
Write-DeployLog "WhatIf: copy $src -> $dst"
continue
}
if ($name -like '*.ps1') {
Copy-ScriptFileWithUtf8Bom -SourcePath $src -DestPath $dst
} else {
Copy-Item -LiteralPath $src -Destination $dst -Force
}
Write-DeployLog "Copied: $name"
}
if (-not $WhatIf) {
Write-TextFileUtf8Bom -Path $VersionStampPath -Text $shareVer
if (-not $SkipInstallTasks) {
$installer = Join-Path $InstallRoot 'Install-DomainMonitors.ps1'
& $PsExe -NoProfile -ExecutionPolicy Bypass -File $installer -Target $Target
if ($LASTEXITCODE -ne 0) {
Write-DeployLog "Install-DomainMonitors exit code: $LASTEXITCODE"
exit $LASTEXITCODE
}
}
}
Write-DeployLog 'Deploy-DomainMonitors finished.'
exit 0
+150 -4
View File
@@ -147,8 +147,129 @@ function Test-CommandLineIsWatchdog {
return ($CommandLine -match '(?i)(^|\s)-Watchdog(\s|$)') return ($CommandLine -match '(?i)(^|\s)-Watchdog(\s|$)')
} }
function Test-RdpMonitorMainProcessRunning {
param([string]$CanonicalScript)
try {
$procs = Get-CimInstance Win32_Process -Filter "Name = 'powershell.exe' OR Name = 'pwsh.exe'" -ErrorAction Stop
foreach ($proc in $procs) {
$cl = [string]$proc.CommandLine
if ($cl -notmatch 'Login_Monitor\.ps1') { continue }
if (Test-CommandLineIsWatchdog -CommandLine $cl) { continue }
$sp = Get-ScriptPathFromCommandLine -CommandLine $cl
if ($null -eq $sp) { continue }
if ([System.IO.Path]::GetFullPath($sp) -ne $CanonicalScript) { continue }
if ([int]$proc.ProcessId -eq $PID) { continue }
return $true
}
} catch { }
return $false
}
function Set-RdpMonitorRestartRequestFromDeploy {
param(
[ValidateSet('settings', 'recycle')]
[string]$Mode = 'recycle',
[string]$Reason = 'deploy'
)
if (-not (Test-Path -LiteralPath $InstallRoot)) {
New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null
}
$restartFile = Join-Path $InstallRoot 'restart.request'
$content = @(
"mode=$Mode"
"reason=$Reason"
"requested_at=$((Get-Date).ToString('o'))"
) -join "`r`n"
[System.IO.File]::WriteAllText($restartFile, $content + "`r`n", $Utf8Bom)
}
function Get-RdpMonitorSettingsRaw {
param([string]$Path)
if (-not (Test-Path -LiteralPath $Path)) { return $null }
try {
return Get-Content -LiteralPath $Path -Raw -ErrorAction Stop
} catch {
return $null
}
}
function Test-RdpMonitorSettingsNeedsSacBootstrap {
param(
[string]$SettingsPath,
[string]$SacClientPath
)
if (-not (Test-Path -LiteralPath $SacClientPath)) { return $true }
$c = Get-RdpMonitorSettingsRaw -Path $SettingsPath
if ([string]::IsNullOrWhiteSpace($c)) { return $true }
if ($c -notmatch '(?m)^\s*\$UseSAC\s*=') { return $true }
if ($c -match '(?m)^\s*\$UseSAC\s*=\s*[''"]off[''"]') {
if ($c -notmatch '(?m)^\s*\$SacApiKey\s*=\s*[''"]sac_[^''"]+[''"]') { return $true }
}
if ($c -match '(?m)^\s*\$SacApiKey\s*=\s*[''"]\s*[''"]') { return $true }
if ($c -notmatch '(?m)^\s*\$SacApiKey\s*=\s*[''"]sac_[^''"]+[''"]') { return $true }
return $false
}
function Sync-RdpMonitorSettingsFromShare {
param(
[string]$ExampleOnShare,
[string]$LocalSettings
)
if (-not (Test-Path -LiteralPath $ExampleOnShare)) {
Write-DeployLog "Предупреждение: на шаре нет login_monitor.settings.example.ps1 — настройки SAC/Telegram не применены."
return
}
$localSac = Join-Path $InstallRoot 'Sac-Client.ps1'
$needsCreate = -not (Test-Path -LiteralPath $LocalSettings)
$needsBootstrap = $needsCreate -or (Test-RdpMonitorSettingsNeedsSacBootstrap -SettingsPath $LocalSettings -SacClientPath $localSac)
if (-not $needsBootstrap) {
Write-DeployLog "login_monitor.settings.ps1: SAC уже настроен, файл не перезаписываем."
return
}
if (-not (Test-Path -LiteralPath $InstallRoot)) {
New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null
}
if ($needsCreate) {
Write-DeployLog "Создан login_monitor.settings.ps1 из example (Telegram + SAC dual)."
} else {
$bak = "$LocalSettings.bak.$(Get-Date -Format 'yyyyMMdd_HHmmss')"
Copy-Item -LiteralPath $LocalSettings -Destination $bak -Force
Write-DeployLog "Апгрейд с версии без SAC: резервная копия $bak, применяем example с шары."
}
Copy-Item -LiteralPath $ExampleOnShare -Destination $LocalSettings -Force
}
function Stop-RdpLoginMonitorMainProcesses { function Stop-RdpLoginMonitorMainProcesses {
param([int]$GracefulWaitSec = 35)
$canonical = [System.IO.Path]::GetFullPath($LocalScript) $canonical = [System.IO.Path]::GetFullPath($LocalScript)
if (-not (Test-RdpMonitorMainProcessRunning -CanonicalScript $canonical)) {
Write-DeployLog "Монитор не запущен — остановка не требуется."
return
}
Write-DeployLog "Graceful recycle: запись restart.request (mode=recycle), без дочернего PowerShell."
Set-RdpMonitorRestartRequestFromDeploy -Mode 'recycle' -Reason 'deploy'
$deadline = (Get-Date).AddSeconds($GracefulWaitSec)
while ((Get-Date) -lt $deadline) {
if (-not (Test-RdpMonitorMainProcessRunning -CanonicalScript $canonical)) {
Write-DeployLog "Монитор завершился gracefully (до $($GracefulWaitSec) с)."
return
}
Start-Sleep -Seconds 1
}
Write-DeployLog "Таймаут graceful recycle — принудительная остановка оставшихся процессов монитора."
try { try {
$procs = Get-CimInstance Win32_Process -Filter "Name = 'powershell.exe' OR Name = 'pwsh.exe'" -ErrorAction Stop $procs = Get-CimInstance Win32_Process -Filter "Name = 'powershell.exe' OR Name = 'pwsh.exe'" -ErrorAction Stop
foreach ($proc in $procs) { foreach ($proc in $procs) {
@@ -159,11 +280,11 @@ function Stop-RdpLoginMonitorMainProcesses {
if ($null -eq $sp) { continue } if ($null -eq $sp) { continue }
if ([System.IO.Path]::GetFullPath($sp) -ne $canonical) { continue } if ([System.IO.Path]::GetFullPath($sp) -ne $canonical) { continue }
if ([int]$proc.ProcessId -eq $PID) { continue } if ([int]$proc.ProcessId -eq $PID) { continue }
Write-DeployLog "Останавливаю процесс монитора PID $($proc.ProcessId) перед обновлением файла." Write-DeployLog "Stop-Process -Force PID $($proc.ProcessId) (fallback)."
Stop-Process -Id $proc.ProcessId -Force -ErrorAction SilentlyContinue Stop-Process -Id $proc.ProcessId -Force -ErrorAction SilentlyContinue
} }
} catch { } catch {
Write-DeployLog "Предупреждение при остановке монитора: $($_.Exception.Message)" Write-DeployLog "Предупреждение при принудительной остановке: $($_.Exception.Message)"
} }
} }
@@ -175,6 +296,9 @@ try {
Write-DeployLog "Deploy: корень дистрибутива: $shareRoot" Write-DeployLog "Deploy: корень дистрибутива: $shareRoot"
$settingsLocal = Join-Path $InstallRoot 'login_monitor.settings.ps1'
$settingsExampleShare = Join-Path $shareRoot 'login_monitor.settings.example.ps1'
if (-not (Test-Path -LiteralPath $sourceScript)) { if (-not (Test-Path -LiteralPath $sourceScript)) {
Write-DeployLog "ОШИБКА: на шаре нет файла: $sourceScript" Write-DeployLog "ОШИБКА: на шаре нет файла: $sourceScript"
exit 0 exit 0
@@ -193,11 +317,19 @@ try {
$localVerRaw = Get-LocalDeployedVersion $localVerRaw = Get-LocalDeployedVersion
Write-DeployLog "Версия на шаре: $shareVerRaw; локально установлено: $(if ($localVerRaw) { $localVerRaw } else { '(нет)' })." Write-DeployLog "Версия на шаре: $shareVerRaw; локально установлено: $(if ($localVerRaw) { $localVerRaw } else { '(нет)' })."
$localSacPath = Join-Path $InstallRoot 'Sac-Client.ps1'
$sourceSacOnShare = Join-Path $shareRoot 'Sac-Client.ps1'
$needsSacBootstrap = (Test-RdpMonitorSettingsNeedsSacBootstrap -SettingsPath $settingsLocal -SacClientPath $localSacPath) `
-or ((-not (Test-Path -LiteralPath $localSacPath)) -and (Test-Path -LiteralPath $sourceSacOnShare))
$cmp = Compare-VersionStrings -Left $shareVerRaw -Right $localVerRaw $cmp = Compare-VersionStrings -Left $shareVerRaw -Right $localVerRaw
if ($null -ne $cmp) { if ($null -ne $cmp) {
if ($cmp -eq 0) { if ($cmp -eq 0) {
Write-DeployLog "Актуально, копирование не требуется." if (-not $needsSacBootstrap) {
exit 0 Write-DeployLog "Актуально, копирование не требуется."
exit 0
}
Write-DeployLog "Версия совпадает ($shareVerRaw), но нужна донастройка SAC — продолжаем деплой."
} }
if ($cmp -lt 0 -and -not $AllowDowngrade) { if ($cmp -lt 0 -and -not $AllowDowngrade) {
Write-DeployLog "На шаре версия старше локальной — пропуск (используйте -AllowDowngrade для отката)." Write-DeployLog "На шаре версия старше локальной — пропуск (используйте -AllowDowngrade для отката)."
@@ -214,6 +346,9 @@ try {
} }
if ($WhatIf) { if ($WhatIf) {
if ($needsSacBootstrap) {
Write-DeployLog "[WhatIf] Применить login_monitor.settings.ps1 из example (SAC/Telegram)."
}
Write-DeployLog "[WhatIf] Скопировать $sourceScript -> $LocalScript; InstallTasks; версия $shareVerRaw" Write-DeployLog "[WhatIf] Скопировать $sourceScript -> $LocalScript; InstallTasks; версия $shareVerRaw"
exit 0 exit 0
} }
@@ -228,6 +363,17 @@ try {
Copy-Item -LiteralPath $sourceScript -Destination $LocalScript -Force Copy-Item -LiteralPath $sourceScript -Destination $LocalScript -Force
Write-DeployLog "Файл скопирован: $LocalScript" Write-DeployLog "Файл скопирован: $LocalScript"
$sourceSac = Join-Path $shareRoot 'Sac-Client.ps1'
$localSac = Join-Path $InstallRoot 'Sac-Client.ps1'
if (Test-Path -LiteralPath $sourceSac) {
Copy-Item -LiteralPath $sourceSac -Destination $localSac -Force
Write-DeployLog "Файл скопирован: $localSac"
} else {
Write-DeployLog "Предупреждение: на шаре нет Sac-Client.ps1 — SAC будет недоступен до следующего деплоя."
}
Sync-RdpMonitorSettingsFromShare -ExampleOnShare $settingsExampleShare -LocalSettings $settingsLocal
$installArgs = @( $installArgs = @(
'-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $LocalScript, '-InstallTasks' '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $LocalScript, '-InstallTasks'
) )
+13
View File
@@ -0,0 +1,13 @@
# Документация RDP-login-monitor
Руководства по развёртыванию и эксплуатации скриптов мониторинга.
| Документ | Описание |
|----------|----------|
| [deploy-rdp-login-monitor.md](deploy-rdp-login-monitor.md) | **Login_Monitor.ps1** — NETLOGON, GPO, задачи планировщика, версии, 4740 на КД |
| [exchange-mail-security.md](exchange-mail-security.md) | **Exchange-MailSecurity.ps1** — очереди, пересылка на внешние адреса, установка |
| [deploy-netlogon-publish.md](deploy-netlogon-publish.md) | Публикация пакета на шару (`update-rdp-monitor.ps1`) |
Общий каталог установки на серверах: **`C:\ProgramData\RDP-login-monitor\`**.
Краткий обзор возможностей — в корневом **[README.md](../README.md)**.
+38
View File
@@ -0,0 +1,38 @@
# Публикация пакета на NETLOGON
Скрипт **`update-rdp-monitor.ps1`** на сервере публикации (например DC3) выполняет `git pull` и копирует файлы в шару.
## Параметры по умолчанию
| Параметр | Значение |
|----------|----------|
| `$RepoPath` | `C:\Soft\Git\RDP-login-monitor` |
| `$NetlogonDest` | `\\b26\NETLOGON\RDP-login-monitor` |
| `$GitUrl` | `https://git.kalinamall.ru/PapaTramp/RDP-login-monitor.git` |
| `$GitBranch` | `main` |
| `$LogFile` | `C:\soft\Logs\update-rdp-monitor.log` |
## Копируемые файлы
- `Login_Monitor.ps1`
- `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`
## Запуск
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\soft\update-rdp-monitor.ps1
powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\soft\update-rdp-monitor.ps1 -WhatIf
```
После pull обязательно проверьте **`version.txt`** на шаре — его номер определяет, подтянут ли обновления на клиентах и Exchange.
Файлы **`*.ps1`** при копировании пересохраняются как **UTF-8 с BOM** (иначе PowerShell 5.1 с NETLOGON может не разобрать кириллицу в скриптах). Скрипты **`Deploy-DomainMonitors.ps1`** и **`Install-DomainMonitors.ps1`** используют **ASCII** в рабочих строках — их можно запускать и без перепубликации.
+236
View File
@@ -0,0 +1,236 @@
# Развёртывание RDP Login Monitor в домене
Монитор ставится в **`C:\ProgramData\RDP-login-monitor\`**, задачи планировщика создаёт сам **`Login_Monitor.ps1`** (параметр `-InstallTasks`). Доставку по сети выполняет **`Deploy-LoginMonitor.ps1`**.
См. также: [exchange-mail-security.md](exchange-mail-security.md) (отдельно, только сервер Exchange).
## Файлы на файловой шаре
Создайте каталог, доступный **конечным компьютерам** на чтение (часто учётная запись компьютера домена), например:
`\\dc.contoso.local\NETLOGON\RDP-login-monitor\`
Минимум для RDP-монитора на всех ПК/серверах:
| Файл | Назначение |
|------|------------|
| `Login_Monitor.ps1` | Основной скрипт (логика мониторинга; без локальных секретов). |
| `Sac-Client.ps1` | Клиент Security Alert Center (обязателен для SAC, копируется рядом с монитором). |
| `login_monitor.settings.example.ps1` | Образец настроек на шаре (Telegram, SAC, SMTP, 4740). |
| `version.txt` | **Одна строка** — номер версии пакета на шаре (см. раздел «Версии» ниже). |
| `Deploy-LoginMonitor.ps1` | Установщик: сравнивает версию, копирует монитор и Sac-Client, вызывает `-InstallTasks`, при необходимости запускает процесс монитора. |
Полный список файлов для публикации на шару — в [deploy-netlogon-publish.md](deploy-netlogon-publish.md).
## Как это работает
1. **`Deploy-LoginMonitor.ps1`** определяет корень дистрибутива:
- параметр **`-SourceShareRoot`** `\\server\share\RDP-login-monitor`, **или**
- если скрипт запущен по UNC, берётся **родительская папка** этого файла.
2. Читается **`version.txt`** на шаре и сравнивается с локальной меткой **`deployed_version.txt`**. Если метки нет — подтягивается **`$ScriptVersion`** из установленного **`Login_Monitor.ps1`**.
3. Версия на шаре **совпадает** с локальной — выход без копирования.
4. Версия на шаре **новее** — остановка процессов монитора → копирование **`Login_Monitor.ps1`** и **`Sac-Client.ps1`** → **`Login_Monitor.ps1 -InstallTasks`** → **`deployed_version.txt`** → запуск монитора (если не **`-SkipStartMonitorAfterUpdate`**).
5. Версия на шаре **старее** — откат блокируется, пока не указан **`-AllowDowngrade`**.
Лог: **`C:\ProgramData\RDP-login-monitor\Logs\deploy.log`**.
## Локальные настройки: `login_monitor.settings.ps1`
На каждом компьютере: **`C:\ProgramData\RDP-login-monitor\login_monitor.settings.ps1`**.
- Секреты и параметры сайта (Telegram, SMTP, **4740**, `$IgnoreAdvapiNetworkLogonSourceIps`) — **только** в этом файле.
- Образец в репозитории и на шаре: **`login_monitor.settings.example.ps1`**.
- **`Deploy-LoginMonitor.ps1`** **не перезаписывает** `login_monitor.settings.ps1` при обновлении скрипта.
- Если файла нет, Deploy **один раз** копирует example → settings (дальше правки только локально).
```powershell
$root = 'C:\ProgramData\RDP-login-monitor'
Copy-Item '\\B26\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`** — строки Base64 в settings.
## Опционально: `ignore.lst`
Файл **`C:\ProgramData\RDP-login-monitor\ignore.lst`** — подавление отдельных алертов **4624/4625/4740**. Синтаксис — в **[README.md](../README.md)** (раздел 7) и **`ignore.lst.example`**. Deploy с шары **`ignore.lst` не копирует**.
## Опционально на КД: блокировки AD (4740)
Включается только если монитор запущен на КД с именем **`$LockoutMonitorDomainController`**. Параметры задаются в **`login_monitor.settings.ps1`** на этом КД (`$NetBiosDomainName`, `$ExchangeIisLogPath`, …).
## Heartbeat и watchdog
- **`Logs\last_heartbeat.txt`** — обновление по **`$HeartbeatInterval`** (по умолчанию 1 ч).
- Нет обновления дольше **`$HeartbeatStaleAlertMultiplier × интервал`** — оповещение.
- Задача **`RDP-Login-Monitor-Watchdog`** — каждые 5 мин проверяет процесс и поднимает при падении.
## Задачи планировщика (`-InstallTasks`)
| Имя | Назначение |
|-----|------------|
| **`RDP-Login-Monitor`** | Запуск при старте ОС |
| **`RDP-Login-Monitor-Watchdog`** | Контроль процесса каждые 5 мин |
Проверка:
```powershell
Get-ScheduledTask -TaskName 'RDP-Login-Monitor','RDP-Login-Monitor-Watchdog' -ErrorAction SilentlyContinue
```
Логи: **`login_monitor.log`**, **`watchdog.log`**.
## GPO (автозагрузка): проверенная схема
1. Файлы на `\\B26\NETLOGON\RDP-login-monitor\`
2. GPO на OU **компьютеров****Сценарии PowerShell** автозагрузки → `Deploy-LoginMonitor.ps1`
3. Security Filtering: группа компьютеров (например `B26\RDP-Login`), права Read + Apply GPO
4. Доступ на шару для **SYSTEM** / Domain Computers
5. После смены membership — **перезагрузка** (не только `gpupdate`)
**Нюансы:** UNC в NETLOGON не копируется в SYSVOL GPO — это нормально. Deploy после `-InstallTasks` делает `schtasks /Run` для немедленного старта монитора.
### Периодический Deploy на серверах без перезагрузок
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\Install-DeployScheduledTask.ps1" `
-TaskName "RDP-Login-Monitor-Deploy" `
-DeployScriptPath "\\B26\NETLOGON\RDP-login-monitor\Deploy-LoginMonitor.ps1" `
-RepeatMinutes 60 `
-RunNow
```
### Ручной deploy
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "\\B26\NETLOGON\RDP-login-monitor\Deploy-LoginMonitor.ps1"
```
Параметры: **`-WhatIf`**, **`-SkipStartMonitorAfterUpdate`**, **`-AllowDowngrade`**.
## Обновление на любой Windows-машине (чеклист)
Цель: новые **`Login_Monitor.ps1`**, **`Sac-Client.ps1`**, при необходимости настройки SAC — без ручного копирования с рабочей станции.
### A. Один раз: публикация на шару (сервер, где есть git)
1. На DC3 (или другом хосте публикации):
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\soft\update-rdp-monitor.ps1
```
Скрипт делает `git pull` с **git.kalinamall.ru** и копирует файлы в `\\b26\NETLOGON\RDP-login-monitor\`.
2. Убедитесь, что на шаре есть **`Sac-Client.ps1`** и в **`version.txt`** — новая версия (например `1.2.0-SAC`).
### B. На каждой целевой машине (автоматически)
Если настроена GPO / задача с **`Deploy-LoginMonitor.ps1`** — достаточно дождаться запуска Deploy (при старте ОС, по расписанию или после `gpupdate /force` + перезагрузки).
Deploy **сам**:
- сравнит `version.txt` на шаре с `C:\ProgramData\RDP-login-monitor\deployed_version.txt`;
- скопирует **`Login_Monitor.ps1`** и **`Sac-Client.ps1`**;
- перерегистрирует задачи (`-InstallTasks`);
- **не трогает** `login_monitor.settings.ps1`.
### C. Ручное обновление одной машины (без GPO)
От **администратора** PowerShell:
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass `
-File "\\b26\NETLOGON\RDP-login-monitor\Deploy-LoginMonitor.ps1"
```
Проверка:
```powershell
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
Get-Content 'C:\ProgramData\RDP-login-monitor\Logs\deploy.log' -Tail 15
```
### Graceful restart (без убийства PowerShell)
После правки **`login_monitor.settings.ps1`** (SAC, Telegram):
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass `
-File "C:\ProgramData\RDP-login-monitor\Login_Monitor.ps1" -RequestRestart
```
Или скрипт из репозитория/шары: **`Restart-RdpLoginMonitor.ps1`**.
Чтобы подхватить **новый `Login_Monitor.ps1` с диска** (после Deploy), нужен **recycle** — новый скрытый процесс, старый завершается сам:
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass `
-File "C:\ProgramData\RDP-login-monitor\Login_Monitor.ps1" -RequestRestart -Recycle
```
**`Deploy-LoginMonitor.ps1`** записывает **`restart.request`** напрямую (без дочернего PowerShell) и ждёт до **35 с**; **`Stop-Process -Force`** только если таймаут.
Сигнал: файл **`C:\ProgramData\RDP-login-monitor\restart.request`** (создаётся автоматически, не редактировать вручную).
### D. Первичная установка (ещё нет ProgramData)
1. Deploy (как в C) — создаст каталог и при отсутствии settings скопирует **`login_monitor.settings.ps1`** из example.
2. Отредактируйте **`C:\ProgramData\RDP-login-monitor\login_monitor.settings.ps1`** (SAC, при необходимости 4740).
3. Проверка SAC:
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass `
-File 'C:\ProgramData\RDP-login-monitor\Login_Monitor.ps1' -CheckSac
```
## Security Alert Center (SAC)
В **`login_monitor.settings.ps1`** на машине (не перезаписывается Deploy):
```powershell
$UseSAC = 'dual' # off | exclusive | dual | fallback
$SacUrl = 'https://sac.kalinamall.ru'
$SacApiKey = 'sac_...' # ключ из SAC
```
Режимы:
- **`dual`** — SAC + Telegram (рекомендуется на переходе);
- **`exclusive`** — только SAC;
- **`fallback`** — SAC, при сбоях — Telegram.
После правок settings перезапуск не обязателен: watchdog поднимет процесс; для проверки:
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass `
-File 'C:\ProgramData\RDP-login-monitor\Login_Monitor.ps1' -CheckSac
```
В SAC UI: **Отчёты** / **События** — события `rdp.login.*`, `report.daily.rdp`, `agent.heartbeat`.
### Диагностика
- Group Policy Operational log
- `Logs\deploy.log`, `login_monitor.log`, `watchdog.log`
## Версии: `version.txt` и `$ScriptVersion`
| Что | Роль |
|-----|------|
| **`version.txt` на шаре** | Триггер обновления для Deploy |
| **`$ScriptVersion` в скрипте** | Версия в логах и Telegram |
Поднимайте **`version.txt`** при каждой выкладке на NETLOGON.
## Безопасность
- Ограничьте ACL на шару (в example/settings могут быть секреты для домена B26).
- DPAPI: **`Encrypt-DpapiForRdpMonitor.ps1`** (значения в **`login_monitor.settings.ps1`**)
- Внутренний git (git.kalinamall.ru): доверенный; секреты допустимы в **`login_monitor.settings.example.ps1`**
## UNC и ExecutionPolicy
При ошибках подписи с FQDN-шары используйте короткое имя DC: `\\DC01\NETLOGON\...` и **`powershell.exe -ExecutionPolicy Bypass -File "..."`**.
+256
View File
@@ -0,0 +1,256 @@
# Exchange Mail Security — руководство
Скрипт **`Exchange-MailSecurity.ps1`** предназначен **только для сервера Microsoft Exchange** с Exchange Management Shell (EMS). Не устанавливается на все компьютеры домена через GPO RDP-монитора.
## Назначение
| Функция | Режим | Расписание (задача планировщика) |
|---------|--------|----------------------------------|
| **Очереди транспорта** | `-Mode Queues` | `RDP-Exchange-TransportQueues` — каждые **10** мин |
| **Пересылка на внешние адреса** (BEC) | `-Mode Inbox` | `RDP-Exchange-InboxRules` — ежедневно **02:00** |
| **Контроль heartbeat** | `-Watchdog` | `RDP-Exchange-MailSecurity-Watchdog` — каждые **15** мин |
Каталог установки (общий с RDP-монитором): **`C:\ProgramData\RDP-login-monitor\`**.
## Что считается «несанкционированной пересылкой»
Скрипт ищет доставку почты **на домены вне организации** (не из списка принятых доменов Exchange).
### 1. Правила Inbox (`Get-InboxRule`)
Для **включённых** правил проверяются:
- `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`).
- Далее — алерт только при **новых** или **изменённых** пересылках (в т.ч. включили ранее отключённое правило).
- **`$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 "\\B26\NETLOGON\RDP-login-monitor\Deploy-DomainMonitors.ps1" -Target Exchange
```
Скрипт копирует файлы в `C:\ProgramData\RDP-login-monitor\`, вызывает **`Install-DomainMonitors.ps1 -Target Exchange`**, который регистрирует задачи планировщика.
Метка версии: `deployed_domain_monitors_version.txt`. Лог: `Logs\deploy_domain_monitors.log`.
### 3. Локальные настройки (один раз)
```powershell
# После 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 = '\\B26\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@kalinamall.ru` | Не вызывать `Get-InboxRule` (битый rule store) |
Переопределение — в **`exchange_monitor.settings.ps1`**.
После починки ящика удалите его из `$SkipInboxScanMailboxes`.
## Формат оповещения (пересылка)
Пример:
```text
📧 Exchange: пересылка на внешний адрес
Тип: InboxRule | MailboxForwarding | TransportRule
Ящик: user@kalinamall.ru
Правило: …
Куда: attacker@gmail.com (внешний)
Важность: Критическая | Высокая
```
## Обновление версии
1. Поднять **`version.txt`** в репозитории и на шаре.
2. `update-rdp-monitor.ps1` или ручное копирование.
3. На Exchange:
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "\\B26\NETLOGON\RDP-login-monitor\Deploy-DomainMonitors.ps1" -Target Exchange
```
## Устранение неполадок
| Симптом | Действие |
|---------|----------|
| «Не удалось загрузить Exchange Management Shell» | Запуск на mailbox/Exchange server; проверить snap-in / `$ExchangeServerFqdn` |
| Нет алертов | Проверить `exchange_monitor.settings.ps1`, Telegram/SMTP, лог `exchange_mail_security.log` |
| Слишком много алертов в первый день | По умолчанию подавлены (`$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 -1
View File
@@ -1,5 +1,6 @@
# Запуск: от администратора на ТОМ ЖЕ компьютере, где будет Login_Monitor.ps1. # Запуск: от администратора на ТОМ ЖЕ компьютере, где будет Login_Monitor.ps1.
# Результат (Base64) вставьте в $TelegramBotTokenProtectedB64 / $TelegramChatIDProtectedB64. # Результат (Base64) вставьте в login_monitor.settings.ps1 или exchange_monitor.settings.ps1:
# $TelegramBotTokenProtectedB64 / $TelegramChatIDProtectedB64 / $MailSmtpPasswordProtectedB64.
param( param(
[Parameter(Mandatory = $true)][string]$PlainText [Parameter(Mandatory = $true)][string]$PlainText
) )
+897
View File
@@ -0,0 +1,897 @@
<#
.SYNOPSIS
Мониторинг Exchange: очереди транспорта, пересылка на внешние адреса (Inbox rules + mailbox forwarding + transport rules).
.DESCRIPTION
Запуск на сервере с Exchange Management Shell (локальный snap-in или remote).
Режимы: -Mode Queues (каждые 10 мин по задаче), -Mode Inbox (ежедневный скан), -Watchdog (проверка heartbeat).
Установка задач: -InstallTasks
.NOTES
Каталог: C:\ProgramData\RDP-login-monitor\
Опционально: exchange_monitor.settings.ps1 в том же каталоге (секреты, whitelist).
#>
[CmdletBinding()]
param(
[ValidateSet('Queues', 'Inbox', 'Watchdog')]
[string]$Mode = 'Queues',
[switch]$InstallTasks,
[switch]$Watchdog
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# ============================================
# КОНФИГУРАЦИЯ
# ============================================
$ScriptVersion = '1.6.7'
$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: *@kalinamall.ru, director*, finance*
$ExcludeMailboxPatterns = @('HealthMailbox*', 'DiscoveryMailbox*', 'SystemMailbox*')
# Skip Inbox rules scan (corrupt rule store / Get-InboxRule fails). Override in exchange_monitor.settings.ps1
$SkipInboxScanMailboxes = @(
'k.selezneva@kalinamall.ru'
)
$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-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'
}
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)
$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)"
$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 ==="
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
+51
View File
@@ -0,0 +1,51 @@
<#
.SYNOPSIS
Register scheduled tasks for domain monitors (Exchange; AD later).
.PARAMETER Target
Exchange - Exchange-MailSecurity.ps1; Ad - reserved.
#>
[CmdletBinding()]
param(
[ValidateSet('Exchange', 'Ad')]
[Parameter(Mandatory = $true)]
[string]$Target
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$InstallRoot = [System.IO.Path]::GetFullPath("$env:ProgramData\RDP-login-monitor")
$PsExe = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe"
function Test-RunningElevated {
$id = [Security.Principal.WindowsIdentity]::GetCurrent()
if ($null -ne $id.User -and $id.User.Value -eq 'S-1-5-18') { return $true }
$p = New-Object Security.Principal.WindowsPrincipal($id)
return $p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
if (-not (Test-RunningElevated)) {
throw 'Run Install-DomainMonitors.ps1 as Administrator.'
}
switch ($Target) {
'Exchange' {
$scriptPath = Join-Path $InstallRoot 'Exchange-MailSecurity.ps1'
if (-not (Test-Path -LiteralPath $scriptPath)) {
throw "Not found: $scriptPath"
}
$notifyPath = Join-Path $InstallRoot 'Notify-Common.ps1'
if (-not (Test-Path -LiteralPath $notifyPath)) {
throw "Not found: $notifyPath"
}
& $PsExe -NoProfile -ExecutionPolicy Bypass -File $scriptPath -InstallTasks
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Write-Host 'Exchange: scheduled tasks registered.'
}
'Ad' {
Write-Host 'AD-SecurityMonitor.ps1 is not implemented yet. Use -Target Exchange.'
exit 1
}
}
exit 0
+1173 -75
View File
File diff suppressed because it is too large Load Diff
+214
View File
@@ -0,0 +1,214 @@
<#
.SYNOPSIS
Общие функции уведомлений (Telegram / SMTP) для скриптов RDP-login-monitor.
.DESCRIPTION
Dot-source после определения Write-NotifyLog в вызывающем скрипте.
Ожидает переменные: $NotifyOrder, $TelegramBotToken, $TelegramChatID,
$MailSmtpHost, $MailFrom, $MailTo и др. (см. Login_Monitor.ps1).
#>
if (-not (Get-Command Write-NotifyLog -ErrorAction SilentlyContinue)) {
function Write-NotifyLog {
param([string]$Message)
Write-Host $Message
}
}
function Unprotect-RdpMonitorDpapiB64 {
param([Parameter(Mandatory = $true)][string]$Base64)
Add-Type -AssemblyName System.Security
$bytes = [Convert]::FromBase64String($Base64.Trim())
$plain = [System.Security.Cryptography.ProtectedData]::Unprotect(
$bytes,
$null,
[System.Security.Cryptography.DataProtectionScope]::LocalMachine
)
return [Text.Encoding]::UTF8.GetString($plain)
}
function Initialize-NotifyCredentials {
param(
[string]$TelegramBotTokenProtectedB64 = '',
[string]$TelegramChatIDProtectedB64 = '',
[ref]$TelegramBotToken,
[ref]$TelegramChatID,
[string]$MailSmtpPasswordProtectedB64 = '',
[ref]$MailSmtpPassword
)
if (-not [string]::IsNullOrWhiteSpace($TelegramBotTokenProtectedB64)) {
$TelegramBotToken.Value = Unprotect-RdpMonitorDpapiB64 -Base64 $TelegramBotTokenProtectedB64
}
if (-not [string]::IsNullOrWhiteSpace($TelegramChatIDProtectedB64)) {
$TelegramChatID.Value = Unprotect-RdpMonitorDpapiB64 -Base64 $TelegramChatIDProtectedB64
}
if (-not [string]::IsNullOrWhiteSpace($MailSmtpPasswordProtectedB64)) {
$MailSmtpPassword.Value = Unprotect-RdpMonitorDpapiB64 -Base64 $MailSmtpPasswordProtectedB64
}
}
function Test-NotifyTelegramConfigured {
return (-not [string]::IsNullOrWhiteSpace($TelegramBotToken)) -and
(-not [string]::IsNullOrWhiteSpace($TelegramChatID))
}
function Test-NotifyEmailConfigured {
return (-not [string]::IsNullOrWhiteSpace($MailSmtpHost)) -and
(-not [string]::IsNullOrWhiteSpace($MailFrom)) -and
(-not [string]::IsNullOrWhiteSpace($MailTo))
}
function Get-NotifyOrderChannels {
$configured = [System.Collections.Generic.List[string]]::new()
if (Test-NotifyTelegramConfigured) { $configured.Add('telegram') | Out-Null }
if (Test-NotifyEmailConfigured) { $configured.Add('email') | Out-Null }
if ([string]::IsNullOrWhiteSpace($NotifyOrder)) {
return @($configured)
}
$requested = [System.Collections.Generic.List[string]]::new()
foreach ($part in ($NotifyOrder -split '[,\s;]+')) {
$p = $part.Trim().ToLowerInvariant()
if ([string]::IsNullOrWhiteSpace($p)) { continue }
$channel = switch -Regex ($p) {
'^(tg|telegram)$' { 'telegram' }
'^(mail|email|e-mail)$' { 'email' }
default {
Write-NotifyLog "NotifyOrder: unknown channel '$part'"
$null
}
}
if ($null -eq $channel) { continue }
if ($configured.Contains($channel) -and -not $requested.Contains($channel)) {
$requested.Add($channel) | Out-Null
}
}
return @($requested)
}
function Get-NotifyChainHuman {
$channels = @(Get-NotifyOrderChannels)
if ($channels.Count -eq 0) { return 'none (Telegram and SMTP not configured)' }
$labels = foreach ($ch in $channels) {
switch ($ch) {
'telegram' { 'Telegram' }
'email' { 'Email (SMTP)' }
default { $ch }
}
}
return ($labels -join ' → ')
}
function ConvertTo-TelegramHtml {
param([string]$Text)
if ($null -eq $Text) { return '' }
return [System.Net.WebUtility]::HtmlEncode([string]$Text)
}
function Send-TelegramMessage {
param([string]$Message)
if (-not (Test-NotifyTelegramConfigured)) {
Write-NotifyLog 'Telegram: token or chat_id missing'
return $false
}
$uri = "https://api.telegram.org/bot$TelegramBotToken/sendMessage"
$body = @{
chat_id = $TelegramChatID
text = $Message
parse_mode = "HTML"
}
try {
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
$null = Invoke-RestMethod -Uri $uri -Method Post -Body $body -ErrorAction Stop -TimeoutSec 30
return $true
} catch {
Write-NotifyLog "Telegram send error: $($_.Exception.Message)"
return $false
}
}
function ConvertTo-EmailHtmlBody {
param([string]$TelegramHtmlMessage)
$inner = [string]$TelegramHtmlMessage
if ([string]::IsNullOrEmpty($inner)) { $inner = '' }
$inner = $inner -replace "`r`n", "<br>`r`n"
return @"
<html>
<body style="font-family:Segoe UI,Arial,sans-serif;font-size:14px;line-height:1.4;">
$inner
</body>
</html>
"@
}
function Send-EmailNotification {
param(
[string]$Message,
[string]$Subject = "RDP Login Monitor"
)
if (-not (Test-NotifyEmailConfigured)) {
Write-NotifyLog 'Email: SMTP not configured'
return $false
}
try {
$toList = @($MailTo -split '[,;]' | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
if ($toList.Count -eq 0) { return $false }
$mailParams = @{
To = $toList
From = $MailFrom.Trim()
Subject = $Subject
Body = (ConvertTo-EmailHtmlBody -TelegramHtmlMessage $Message)
BodyAsHtml = $true
SmtpServer = $MailSmtpHost.Trim()
Port = [int]$MailSmtpPort
Encoding = [System.Text.Encoding]::UTF8
ErrorAction = 'Stop'
}
if ($MailSmtpSsl -or $MailSmtpStartTls) { $mailParams['UseSsl'] = $true }
if (-not [string]::IsNullOrWhiteSpace($MailSmtpUser)) {
$securePass = if ([string]::IsNullOrWhiteSpace($MailSmtpPassword)) {
New-Object System.Security.SecureString
} else {
ConvertTo-SecureString $MailSmtpPassword -AsPlainText -Force
}
$mailParams['Credential'] = New-Object System.Management.Automation.PSCredential($MailSmtpUser.Trim(), $securePass)
}
Send-MailMessage @mailParams
return $true
} catch {
Write-NotifyLog "Email send error: $($_.Exception.Message)"
return $false
}
}
function Send-MonitorNotification {
param(
[string]$Message,
[string]$EmailSubject = "RDP Login Monitor"
)
$channels = @(Get-NotifyOrderChannels)
if ($channels.Count -eq 0) {
Write-NotifyLog 'Notification skipped: no channels configured'
return $false
}
$anyOk = $false
foreach ($ch in $channels) {
$ok = switch ($ch) {
'telegram' { Send-TelegramMessage -Message $Message }
'email' { Send-EmailNotification -Message $Message -Subject $EmailSubject }
default { $false }
}
if ($ok) { $anyOk = $true }
}
return $anyOk
}
+42 -17
View File
@@ -1,24 +1,29 @@
# RDP Login Monitor # RDP Login Monitor
PowerShell-набор для мониторинга входов в Windows с отправкой уведомлений в Telegram. PowerShell-набор для мониторинга входов в Windows с уведомлениями в Telegram и/или Email (SMTP).
## Актуальная схема (рекомендуется) ## Актуальная схема (рекомендуется)
- Базовый путь установки: **`C:\ProgramData\RDP-login-monitor\`**. - Базовый путь установки: **`C:\ProgramData\RDP-login-monitor\`**.
- Основной скрипт: **`Login_Monitor.ps1`** — журнал Security **`4624`/`4625`** (логика зависит от типа ОС: рабочая станция или сервер/КД), при наличии журнала — **Remote Connection Manager `1149`** (часто актуально для РС с RDP), при роли **RD Gateway****`302`/`303`**, **ежедневный отчёт** в Telegram (активные сессии через `quser`), **heartbeat**, **ротация логов**, уведомления в Telegram. - Основной скрипт: **`Login_Monitor.ps1`** — журнал Security **`4624`/`4625`** (логика зависит от типа ОС: рабочая станция или сервер/КД), при всплеске **`4625`** — **агрегированные оповещения** (два порога: IP+пользователь и только IP), при наличии журнала — **Remote Connection Manager `1149`** (часто актуально для РС с RDP), при роли **RD Gateway****`302`/`303`**, на **КД, где запущен монитор** (имя совпадает с **`$LockoutMonitorDomainController`**) — **`4740`** (блокировка УЗ + IP из IIS ActiveSync), **ежедневный отчёт** (активные сессии через `quser`), **heartbeat**, **ротация логов**, уведомления в Telegram и/или Email.
- Установка задач: запуск **`Login_Monitor.ps1 -InstallTasks`** создаёт: - Установка задач: запуск **`Login_Monitor.ps1 -InstallTasks`** создаёт:
- `RDP-Login-Monitor` (основной монитор), - `RDP-Login-Monitor` (основной монитор),
- `RDP-Login-Monitor-Watchdog` (контроль процесса каждые 5 минут). - `RDP-Login-Monitor-Watchdog` (контроль процесса каждые 5 минут).
- Доменная доставка и обновления: **`Deploy-LoginMonitor.ps1`** + **`version.txt`** с шары `NETLOGON`. После успешного деплоя в приветственном сообщении Telegram может появиться отметка об обновлении (файл **`deploy_last_update.txt`** рядом с логами). - Доменная доставка и обновления: **`Deploy-LoginMonitor.ps1`** + **`version.txt`** с шары `NETLOGON`. После успешного деплоя в приветственном сообщении (Telegram/Email) может появиться отметка об обновлении (файл **`deploy_last_update.txt`** рядом с логами).
- Для полной инструкции по деплою/GPO используйте **[DEPLOY.md](DEPLOY.md)**. - Документация по развёртыванию: **[Docs/README.md](Docs/README.md)** (RDP-монитор, Exchange, NETLOGON).
- **`Encrypt-DpapiForRdpMonitor.ps1`** — опционально для подготовки DPAPI-строк токена/chat id. - **`Encrypt-DpapiForRdpMonitor.ps1`** — опционально для подготовки DPAPI-строк токена/chat id и пароля SMTP (`$MailSmtpPasswordProtectedB64` в файле настроек).
- **Локальные настройки RDP-монитора:** **`login_monitor.settings.ps1`** в каталоге установки (образец **`login_monitor.settings.example.ps1`**). При автообновлении **`Login_Monitor.ps1`** с шары файл настроек **не перезаписывается** (как **`exchange_monitor.settings.ps1`** для Exchange).
- **Security Alert Center (SAC):** модуль **`Sac-Client.ps1`** (копируется вместе с `Login_Monitor.ps1`). Режимы **`$UseSAC`**: `off` | `exclusive` | `dual` | `fallback` — контракт в репозитории **security-alert-center** (`docs/agent-integration.md`). Версия релиза агента: **`1.2.5-SAC`** (`$ScriptVersion` и `version.txt`).
## Что изменилось (важное) ## Что изменилось (важное)
- **Локальные настройки RDP-монитора** вынесены в **`login_monitor.settings.ps1`** (образец **`login_monitor.settings.example.ps1`**). При автообновлении **`Login_Monitor.ps1`** с шары секреты и параметры КД **не слетают**. Deploy при отсутствии settings создаёт файл один раз из example на шаре.
- **Кодировка `.ps1`**: в репозитории добавлены `.editorconfig` и `.gitattributes`, чтобы `*.ps1` по умолчанию сохранялись как **UTF-8 with BOM** и с **CRLF** (это сильно снижает “кракозябры” и ошибки парсинга PowerShell). - **Кодировка `.ps1`**: в репозитории добавлены `.editorconfig` и `.gitattributes`, чтобы `*.ps1` по умолчанию сохранялись как **UTF-8 with BOM** и с **CRLF** (это сильно снижает “кракозябры” и ошибки парсинга PowerShell).
- **Кодировка логов**: `login_monitor.log` / `watchdog.log` пишутся как **UTF-8 с BOM** (и при необходимости BOM добавляется к уже существующему файлу), чтобы в **FAR/старых просмотрщиках** не было ситуации “в консоли нормально, а в файле РЈРІРµ…” из‑за неверной авто-кодировки. - **Кодировка логов**: `login_monitor.log` / `watchdog.log` пишутся как **UTF-8 с BOM** (и при необходимости BOM добавляется к уже существующему файлу), чтобы в **FAR/старых просмотрщиках** не было ситуации “в консоли нормально, а в файле РЈРІРµ…” из‑за неверной авто-кодировки.
- **`auditpol` на русской Windows**: настройка/проверка аудита опирается на категорию **`Вход/выход`** и подкатегории **`Вход в систему` / `Выход из системы`** (ожидается строка **`Успех и сбой`**). Это устраняет ошибки вида `0x00000057` из‑за несуществующего на RU ОС имени `Logon`. - **`auditpol` на русской Windows**: настройка/проверка аудита опирается на категорию **`Вход/выход`** и подкатегории **`Вход в систему` / `Выход из системы`** (ожидается строка **`Успех и сбой`**). Это устраняет ошибки вида `0x00000057` из‑за несуществующего на RU ОС имени `Logon`.
- **Стабильность**: `auditpol` запускается через `cmd.exe` с перехватом `stdout+stderr`, чтобы не ломать выполнение при `$ErrorActionPreference = "Stop"`. - **Стабильность**: `auditpol` вызывается по полному пути `%SystemRoot%\System32\auditpol.exe` (без зависимости от PATH), stdout+stderr объединяются через `ProcessStartInfo`.
- **Агрегация 4625 (брутфорс)**: при включённом `$FailedLogonRateLimitEnabled` — уровень 1: **5** неудачных попыток за **60** с с одного источника для **одной** учётной записи (IP+user); уровень 2: **12** попыток за **60** с с одного IP (несколько логинов). Пока порог не достигнут — поштучные 4625; при всплеске — сводные алерты, одиночные подавляются. Параметры в начале `Login_Monitor.ps1`. Автоблокировка IP не выполняется.
- **Exchange Mail Security** (`Exchange-MailSecurity.ps1`): на **сервере Exchange** — очереди, пересылка на внешние адреса, watchdog. Руководство: **[Docs/exchange-mail-security.md](Docs/exchange-mail-security.md)**.
## 1) Подготовка ## 1) Подготовка
@@ -26,14 +31,21 @@ PowerShell-набор для мониторинга входов в Windows с
- `C:\ProgramData\RDP-login-monitor\` - `C:\ProgramData\RDP-login-monitor\`
2. Скопируйте в неё как минимум: 2. Скопируйте в неё как минимум:
- `Login_Monitor.ps1` - `Login_Monitor.ps1`
- (для доменного развёртывания отдельно на шаре) `Deploy-LoginMonitor.ps1` и `version.txt`. - `Sac-Client.ps1`
3. Откройте `Login_Monitor.ps1` и задайте токен/чат: - `login_monitor.settings.example.ps1` → переименуйте в **`login_monitor.settings.ps1`** и задайте параметры (см. п. 3)
- `$TelegramBotToken` или `...ProtectedB64` - (для доменного развёртывания отдельно на шаре) `Deploy-LoginMonitor.ps1`, `version.txt` и `login_monitor.settings.example.ps1`
- `$TelegramChatID` или `...ProtectedB64` 3. Настройте **`C:\ProgramData\RDP-login-monitor\login_monitor.settings.ps1`** (не редактируйте секреты в `Login_Monitor.ps1` — они перезапишутся при деплое):
- **Telegram:** `$TelegramBotToken` / `$TelegramChatID` или `...ProtectedB64`
- **Email (SMTP):** `$MailSmtpHost`, `$MailFrom`, `$MailTo`, `$MailSmtpPort` (по умолчанию 587), при необходимости `$MailSmtpUser` / `$MailSmtpPassword` (или `$MailSmtpPasswordProtectedB64` через DPAPI), `$MailSmtpStartTls` / `$MailSmtpSsl`
- **Порядок:** `$NotifyOrder` — пусто = авто (Telegram → Email, только настроенные); иначе `telegram,email` или `email` и т.п. (допускаются `tg`, `mail`)
- **SAC (опционально):** `$UseSAC`, `$SacUrl`, `$SacApiKey` — см. блок в **`login_monitor.settings.example.ps1`**. Проверка: `Login_Monitor.ps1 -CheckSac`
- Пошаговое обновление по домену: **[Docs/deploy-rdp-login-monitor.md](Docs/deploy-rdp-login-monitor.md)** (раздел «Обновление на любой Windows-машине»)
4. Запускайте с правами администратора (чтение `Security` журнала и регистрация задач). 4. Запускайте с правами администратора (чтение `Security` журнала и регистрация задач).
5. Логи и служебные файлы будут в: 5. Логи и служебные файлы будут в:
- `C:\ProgramData\RDP-login-monitor\Logs\` - `C:\ProgramData\RDP-login-monitor\Logs\`
6. (Опционально) Подавление части алертов по списку — см. раздел **«7) ignore.lst»** ниже. 6. (Опционально) Подавление части алертов по списку — см. раздел **«7) ignore.lst»** ниже.
7. (Опционально) Мониторинг блокировок AD на КД — в **`login_monitor.settings.ps1`**: **`$LockoutMonitorDomainController`** (короткое имя узла, на котором **установлен и запущен** монитор), **`$NetBiosDomainName`**, **`$ExchangeIisLogPath`**, **`$ExchangeIisLogMinutesBeforeLockout`** (по умолчанию 30), **`$ExchangeIisLogTailLines`** (по умолчанию 5000), **`$ExchangeServerHostForIisExclude`**. В оповещении: пользователь из 4740 и IP из IIS за окно до блокировки. В **`ignore.lst`** префикс **`4740:`** или **`all:`** — см. **`ignore.lst.example`**.
8. Heartbeat: при отсутствии обновления **`Logs\last_heartbeat.txt`** дольше **`$HeartbeatStaleAlertMultiplier` × `$HeartbeatInterval`** (по умолчанию 2×1 ч) — оповещение в Telegram/Email.
## 2) Ручной запуск ## 2) Ручной запуск
@@ -57,7 +69,7 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\RDP-logi
Скрипт сам зарегистрирует `RDP-Login-Monitor` и `RDP-Login-Monitor-Watchdog`, а также запросит немедленный первый запуск задач. Скрипт сам зарегистрирует `RDP-Login-Monitor` и `RDP-Login-Monitor-Watchdog`, а также запросит немедленный первый запуск задач.
Для доменной установки/обновления с шары вручную ничего в планировщике на клиенте настраивать не требуется: используйте `Deploy-LoginMonitor.ps1` (подробно в `DEPLOY.md`). Для доменной установки/обновления с шары вручную ничего в планировщике на клиенте настраивать не требуется: используйте `Deploy-LoginMonitor.ps1` (подробно в [Docs/deploy-rdp-login-monitor.md](Docs/deploy-rdp-login-monitor.md)).
## 4) Что проверять после запуска ## 4) Что проверять после запуска
@@ -66,7 +78,9 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\RDP-logi
- `C:\ProgramData\RDP-login-monitor\Logs\watchdog.log` - `C:\ProgramData\RDP-login-monitor\Logs\watchdog.log`
- Heartbeat: - Heartbeat:
- `C:\ProgramData\RDP-login-monitor\Logs\last_heartbeat.txt` обновляется по интервалу **`$HeartbeatInterval`** (по умолчанию раз в час). - `C:\ProgramData\RDP-login-monitor\Logs\last_heartbeat.txt` обновляется по интервалу **`$HeartbeatInterval`** (по умолчанию раз в час).
- Ежедневный отчёт: после первого прохождения дневного слота (по умолчанию **09:00**, задаётся **`$DailyReportHour`** / **`$DailyReportMinute`** в `Login_Monitor.ps1`) в Telegram уходит сводка по **`quser`**; метка последнего отчёта — `Logs\last_daily_report.txt`. - Ежедневный отчёт: после первого прохождения дневного слота (по умолчанию **09:00**, задаётся **`$DailyReportHour`** / **`$DailyReportMinute`** в `Login_Monitor.ps1`) уходит сводка по **`quser`** (Telegram/Email); метка последнего отчёта — `Logs\last_daily_report.txt`.
- Stale heartbeat: если **`last_heartbeat.txt`** не обновлялся дольше **`$HeartbeatStaleAlertMultiplier` × `$HeartbeatInterval`** — оповещение в Telegram/Email (см. п. 8 подготовки).
- При старте в Telegram/Email: строка **«Каналы уведомлений»** (фактический порядок доставки), плюс режим RDS/4740 по конфигурации.
- Telegram при старте: при установленном **RD Session Host** (или аналогичных компонентах RDS, не только шлюз) — строка про входы по RDP/RDS на этом сервере; при доступном журнале **RD Gateway** — отдельная строка про подключения к **внутренним целевым ПК** через шлюз (302/303). Узел только с ролью RD Gateway не дублирует формулировку «хост сессий». - Telegram при старте: при установленном **RD Session Host** (или аналогичных компонентах RDS, не только шлюз) — строка про входы по RDP/RDS на этом сервере; при доступном журнале **RD Gateway** — отдельная строка про подключения к **внутренним целевым ПК** через шлюз (302/303). Узел только с ролью RD Gateway не дублирует формулировку «хост сессий».
## 5) Автоматический перезапуск при падении ## 5) Автоматический перезапуск при падении
@@ -83,12 +97,13 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\RDP-logi
- **`Install-DeployScheduledTask.ps1`** — helper для периодического запуска **`Deploy-LoginMonitor.ps1`** с шары (см. **[DEPLOY.md](DEPLOY.md)**). - **`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`**. - **`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). - **`ignore.lst.example`** в репозитории — образец файла **`ignore.lst`** для подавления отдельных уведомлений Security (см. раздел 7).
- **`login_monitor.settings.example.ps1`** — образец **`login_monitor.settings.ps1`** (Telegram, SMTP, 4740, локальные IP-исключения). Deploy при первой установке может создать `login_monitor.settings.ps1` из example автоматически.
## 7) Подавление уведомлений Security: `ignore.lst` ## 7) Подавление уведомлений Security: `ignore.lst`
В каталоге установки можно положить файл **`C:\ProgramData\RDP-login-monitor\ignore.lst`** (рядом с **`Login_Monitor.ps1`**). Правила из списка проверяются **только** для Telegram-уведомлений по событиям **`4624`/`4625`** журнала Security (успех/неудача входа). Жёстко заданные в скрипте исключения (`ExcludedUsers`, локальный IP, сервисные учётные записи и т.д.) по-прежнему действуют для всех типов событий; **`ignore.lst`** добавляет к ним **дополнительные** совпадения именно для **4624/4625**. В каталоге установки можно положить файл **`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 **этим файлом не настраиваются** (для `1149` список не используется, даже если формально вызывается общая функция фильтрации). События **RD Gateway (`302`/`303`)**, **RCM `1149`**, ежедневный отчёт и heartbeat **этим файлом не настраиваются**.
### Как читается файл ### Как читается файл
@@ -98,7 +113,17 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\RDP-logi
- Строка с **`:`**: берётся **первая** двоеточие — всё слева (после обрезки пробелов) определяет тип правила, всё справа — значение. Если справа пусто, строка игнорируется. - Строка с **`:`**: берётся **первая** двоеточие — всё слева (после обрезки пробелов) определяет тип правила, всё справа — значение. Если справа пусто, строка игнорируется.
- Строка **без** **`:`**: целиком трактуется как правило типа «любое совпадение» (см. ниже). - Строка **без** **`:`**: целиком трактуется как правило типа «любое совпадение» (см. ниже).
### Типы правил (левая часть до первого `:`) ### Префикс области (в самом начале строки, до типа правила)
| Префикс | События |
| --- | --- |
| *(нет)* | **4624**, **4625** |
| `4740:`, `lockout:`, `блокир:` | **4740** |
| `all:`, `*:` | **4624**, **4625**, **4740** |
Пример: `4740:user:svc_sync` — не слать оповещение о блокировке этой УЗ.
### Типы правил (левая часть до первого `:` после префикса области)
| Левая часть (фрагменты совпадают как regex, без учёта регистра) | Поле события | | Левая часть (фрагменты совпадают как regex, без учёта регистра) | Поле события |
| --- | --- | | --- | --- |
@@ -121,10 +146,10 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\RDP-logi
### Примеры и поставка ### Примеры и поставка
- Расширенные примеры строк — в **`ignore.lst.example`** в корне репозитория (скопируйте на сервер как **`ignore.lst`** и отредактируйте). - Расширенные примеры строк — в **`ignore.lst.example`** в корне репозитория (скопируйте на сервер как **`ignore.lst`** и отредактируйте).
- **`Deploy-LoginMonitor.ps1`** этот файл **не копирует**: правила обычно разные на каждой машине; при необходимости создайте `ignore.lst` вручную или через вашу систему конфигурации. - **`Deploy-LoginMonitor.ps1`** **`ignore.lst`** и **`login_monitor.settings.ps1`** **не копирует и не перезаписывает** правила и секреты локальны; при отсутствии settings Deploy создаёт его один раз из **`login_monitor.settings.example.ps1`** на шаре.
## Ключевые слова (для поиска репозитория) ## Ключевые слова (для поиска репозитория)
`rdp`, `rd-gateway`, `rdp-gateway`, `rds`, `remote-desktop`, `windows-security-log`, `eventlog`, `event-id-4624`, `event-id-4625`, `event-id-302`, `event-id-303`, `powershell`, `telegram-bot`, `watchdog`, `gpo`, `netlogon`, `domain-deployment`, `windows-server`, `monitoring` `rdp`, `rd-gateway`, `rdp-gateway`, `rds`, `remote-desktop`, `windows-security-log`, `eventlog`, `event-id-4624`, `event-id-4625`, `event-id-4740`, `event-id-302`, `event-id-303`, `account-lockout`, `active-sync`, `exchange`, `iis`, `smtp`, `email`, `powershell`, `telegram-bot`, `watchdog`, `gpo`, `netlogon`, `domain-deployment`, `windows-server`, `monitoring`
+38 -18
View File
@@ -1,24 +1,27 @@
# RDP Login Monitor # RDP Login Monitor
PowerShell toolkit for monitoring Windows logons with Telegram notifications. PowerShell toolkit for monitoring Windows logons with Telegram and/or Email (SMTP) notifications.
## Recommended layout ## Recommended layout
- Installation root: **`C:\ProgramData\RDP-login-monitor\`**. - Installation root: **`C:\ProgramData\RDP-login-monitor\`**.
- Main script: **`Login_Monitor.ps1`** — Security log **`4624`/`4625`** (behavior depends on OS type: workstation vs server/domain controller), optional **Remote Connection Manager `1149`** when the log is available (often useful for RDP-enabled workstations), **RD Gateway** events **`302`/`303`** when the gateway role/log is present, **daily report** to Telegram (active sessions via `quser`), **heartbeat**, **log rotation**, Telegram alerts. - Main script: **`Login_Monitor.ps1`** — Security log **`4624`/`4625`** (behavior depends on OS type: workstation vs server/domain controller), **aggregated `4625` burst alerts** (two tiers: IP+user and IP-only), optional **Remote Connection Manager `1149`** when the log is available (often useful for RDP-enabled workstations), **RD Gateway** events **`302`/`303`** when the gateway role/log is present, on the **DC where the monitor runs** (hostname matches **`$LockoutMonitorDomainController`**) — **`4740`** (account lockout + IPs from IIS ActiveSync), **daily report** (active sessions via `quser`), **heartbeat**, **log rotation**, alerts via Telegram and/or Email.
- Scheduled tasks: run **`Login_Monitor.ps1 -InstallTasks`** to register: - Scheduled tasks: run **`Login_Monitor.ps1 -InstallTasks`** to register:
- `RDP-Login-Monitor` (main monitor), - `RDP-Login-Monitor` (main monitor),
- `RDP-Login-Monitor-Watchdog` (process health check every 5 minutes). - `RDP-Login-Monitor-Watchdog` (process health check every 5 minutes).
- Domain delivery and upgrades: **`Deploy-LoginMonitor.ps1`** + **`version.txt`** on a share such as `NETLOGON`. After a successful deploy, the startup Telegram message may include an update note (file **`deploy_last_update.txt`** next to logs). - Domain delivery and upgrades: **`Deploy-LoginMonitor.ps1`** + **`version.txt`** on a share such as `NETLOGON`. After a successful deploy, the startup notification may include an update note (file **`deploy_last_update.txt`** next to logs).
- Full deploy/GPO guidance: **[DEPLOY.md](DEPLOY.md)**. - Deployment docs: **[Docs/README.md](Docs/README.md)** (RDP monitor, Exchange, NETLOGON).
- **`Encrypt-DpapiForRdpMonitor.ps1`** — optional helper to prepare DPAPI-protected Base64 for the bot token / chat id. - **`Encrypt-DpapiForRdpMonitor.ps1`** — optional helper to prepare DPAPI-protected Base64 for the bot token / chat id and SMTP password (`$MailSmtpPasswordProtectedB64` in the settings file).
- **RDP monitor local settings:** **`login_monitor.settings.ps1`** in the install directory (template **`login_monitor.settings.example.ps1`**). When **`Login_Monitor.ps1`** is auto-updated from the share, the settings file is **not overwritten** (same pattern as **`exchange_monitor.settings.ps1`** for Exchange).
## Notable behavior ## Notable behavior
- **`.ps1` encoding**: `.editorconfig` and `.gitattributes` encourage **`*.ps1`** as **UTF-8 with BOM** and **CRLF**, reducing mojibake and PowerShell parse issues. - **`.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. - **Log encoding**: `login_monitor.log` / `watchdog.log` are written as **UTF-8 with BOM** (BOM is applied to existing files if missing) so viewers like **FAR Manager** do not mis-detect encoding.
- **`auditpol` on Russian Windows**: auditing checks use the **`Вход/выход`** category and **`Вход в систему` / `Выход из системы`** subcategories (expect **`Успех и сбой`**), avoiding errors such as `0x00000057` when English names like `Logon` are absent on a localized OS. - **`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. - **Stability**: `auditpol` is invoked via full path `%SystemRoot%\System32\auditpol.exe` (no PATH dependency); stdout and stderr are merged via `ProcessStartInfo`.
- **`4625` burst alerts**: when `$FailedLogonRateLimitEnabled` is true — tier 1: **5** failures in **60** s per **IP+user**; tier 2: **12** in **60** s per **IP** (spray). Below thresholds, individual `4625` alerts are sent; during a burst, aggregated alerts replace per-event noise. No automatic IP blocking. Tune at the top of `Login_Monitor.ps1`.
- **Exchange Mail Security** (`Exchange-MailSecurity.ps1`): Exchange server only — queues, external forwarding, watchdog. See **[Docs/exchange-mail-security.md](Docs/exchange-mail-security.md)**.
## 1) Preparation ## 1) Preparation
@@ -26,14 +29,18 @@ PowerShell toolkit for monitoring Windows logons with Telegram notifications.
- `C:\ProgramData\RDP-login-monitor\` - `C:\ProgramData\RDP-login-monitor\`
2. Copy at least: 2. Copy at least:
- `Login_Monitor.ps1` - `Login_Monitor.ps1`
- (for domain rollout on a share) `Deploy-LoginMonitor.ps1` and `version.txt`. - `login_monitor.settings.example.ps1` → rename to **`login_monitor.settings.ps1`** and configure (see step 3)
3. Edit `Login_Monitor.ps1` and set the bot token / chat: - (for domain rollout on a share) `Deploy-LoginMonitor.ps1`, `version.txt`, and `login_monitor.settings.example.ps1`
- `$TelegramBotToken` or `$TelegramBotTokenProtectedB64` 3. Configure **`C:\ProgramData\RDP-login-monitor\login_monitor.settings.ps1`** (do not put secrets in `Login_Monitor.ps1` — they are overwritten on deploy):
- `$TelegramChatID` or `$TelegramChatIDProtectedB64` - **Telegram:** `$TelegramBotToken` / `$TelegramChatID` or `...ProtectedB64`
- **Email (SMTP):** `$MailSmtpHost`, `$MailFrom`, `$MailTo`, `$MailSmtpPort` (default 587), optionally `$MailSmtpUser` / `$MailSmtpPassword` (or `$MailSmtpPasswordProtectedB64` via DPAPI), `$MailSmtpStartTls` / `$MailSmtpSsl`
- **Order:** `$NotifyOrder` — empty = auto (Telegram → Email, configured channels only); otherwise `telegram,email`, `email`, etc. (`tg`, `mail` are accepted)
4. Run elevated (Security log access and task registration). 4. Run elevated (Security log access and task registration).
5. Logs and auxiliary files: 5. Logs and auxiliary files:
- `C:\ProgramData\RDP-login-monitor\Logs\` - `C:\ProgramData\RDP-login-monitor\Logs\`
6. (Optional) Suppress some Security alerts via **`ignore.lst`** — see **section 7** below. 6. (Optional) Suppress some Security alerts via **`ignore.lst`** — see **section 7** below.
7. (Optional) AD account lockout monitoring on a DC — in **`login_monitor.settings.ps1`**: **`$LockoutMonitorDomainController`** (short hostname of the machine **where the monitor is installed and running**), **`$NetBiosDomainName`**, **`$ExchangeIisLogPath`**, **`$ExchangeIisLogMinutesBeforeLockout`** (default 30), **`$ExchangeIisLogTailLines`** (default 5000), **`$ExchangeServerHostForIisExclude`**. Alerts include the user from event **4740** and client IPs from IIS within the time window before lockout. In **`ignore.lst`** use prefix **`4740:`** or **`all:`** — see **`ignore.lst.example`**.
8. Heartbeat: if **`Logs\last_heartbeat.txt`** is not updated for longer than **`$HeartbeatStaleAlertMultiplier` × `$HeartbeatInterval`** (default 2×1 h) — alert via Telegram/Email.
## 2) Manual run ## 2) Manual run
@@ -57,7 +64,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. The script registers `RDP-Login-Monitor` and `RDP-Login-Monitor-Watchdog`, then triggers an immediate first run.
For domain deployment from a share you do not configure the scheduler on clients by hand — use `Deploy-LoginMonitor.ps1` (see `DEPLOY.md`). For domain deployment from a share you do not configure the scheduler on clients by hand — use `Deploy-LoginMonitor.ps1` (see [Docs/deploy-rdp-login-monitor.md](Docs/deploy-rdp-login-monitor.md)).
## 4) Post-install checks ## 4) Post-install checks
@@ -66,8 +73,10 @@ For domain deployment from a share you do not configure the scheduler on clients
- `C:\ProgramData\RDP-login-monitor\Logs\watchdog.log` - `C:\ProgramData\RDP-login-monitor\Logs\watchdog.log`
- Heartbeat: - Heartbeat:
- `C:\ProgramData\RDP-login-monitor\Logs\last_heartbeat.txt` updates on **`$HeartbeatInterval`** (hourly by default). - `C:\ProgramData\RDP-login-monitor\Logs\last_heartbeat.txt` updates on **`$HeartbeatInterval`** (hourly by default).
- Daily report: after the first daily window (default **09:00**, controlled by **`$DailyReportHour`** / **`$DailyReportMinute`** in `Login_Monitor.ps1`), Telegram receives a `quser` summary; last run marker: `Logs\last_daily_report.txt`. - Daily report: after the first daily window (default **09:00**, controlled by **`$DailyReportHour`** / **`$DailyReportMinute`** in `Login_Monitor.ps1`), a `quser` summary is sent (Telegram/Email); last run marker: `Logs\last_daily_report.txt`.
- Startup Telegram message: with **RD Session Host** (or broader RDS session components, not gateway-only) you get the RDS/RDP session-host line; when the **RD Gateway** log is available you get a separate line about connections to **internal targets** through the gateway (302/303). A gateway-only node does not duplicate the “session host” wording. - Stale heartbeat: if **`last_heartbeat.txt`** is stale beyond **`$HeartbeatStaleAlertMultiplier` × `$HeartbeatInterval`** — alert via Telegram/Email (see preparation step 8).
- At startup (Telegram/Email): **notification channels** line (actual delivery order) plus RDS/4740 mode per configuration.
- Startup message (Telegram): with **RD Session Host** (or broader RDS session components, not gateway-only) you get the RDS/RDP session-host line; when the **RD Gateway** log is available you get a separate line about connections to **internal targets** through the gateway (302/303). A gateway-only node does not duplicate the “session host” wording.
## 5) Automatic restart on failure ## 5) Automatic restart on failure
@@ -83,12 +92,13 @@ For domain deployment from a share you do not configure the scheduler on clients
- **`Install-DeployScheduledTask.ps1`** — helper to run **`Deploy-LoginMonitor.ps1`** from a share on a schedule (see **[DEPLOY.md](DEPLOY.md)**). - **`Install-DeployScheduledTask.ps1`** — helper to run **`Deploy-LoginMonitor.ps1`** from a share on a schedule (see **[DEPLOY.md](DEPLOY.md)**).
- **`Watchdog_RDP_Monitor.ps1`** and **`Install-ScheduledTasks.ps1`** — **alternate** layout with a separate watchdog script and default paths under **`D:\Soft`**. For new installs, prefer the built-in **`-Watchdog`** in **`Login_Monitor.ps1`** and tasks **`RDP-Login-Monitor`** / **`RDP-Login-Monitor-Watchdog`**. - **`Watchdog_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). - **`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` ## 7) Suppressing Security alerts: `ignore.lst`
Place a file **`C:\ProgramData\RDP-login-monitor\ignore.lst`** next to **`Login_Monitor.ps1`**. Rules in this list are evaluated **only** for Telegram notifications from Security events **`4624`/`4625`** (successful/failed logons). Built-in exclusions in the script (`ExcludedUsers`, loopback IPs, service-style accounts, etc.) still apply to all event paths; **`ignore.lst`** adds **extra** matches **for 4624/4625 only**. Place a file **`C:\ProgramData\RDP-login-monitor\ignore.lst`** next to **`Login_Monitor.ps1`**. By default rules apply to **`4624`/`4625`**; prefix **`4740:`** (or **`lockout:`**) — account lockouts only; **`all:`** — logons and **4740**. For **4740**, rule type **`ip:`** is matched against IPs from IIS ActiveSync. Built-in script exclusions still apply to all event types except **4740** (lockouts use **`ignore.lst`** and built-in user checks only).
**RD Gateway (`302`/`303`)**, **RCM `1149`**, the daily report, and heartbeat **are not controlled** by this file (for `1149` the list is not applied, even though a shared filter function runs). **RD Gateway (`302`/`303`)**, **RCM `1149`**, the daily report, and heartbeat **are not controlled** by this file.
### How the file is loaded ### How the file is loaded
@@ -98,7 +108,17 @@ Place a file **`C:\ProgramData\RDP-login-monitor\ignore.lst`** next to **`Login_
- If the line contains **`:`**, the **first** colon splits the line: the left part (trimmed) selects the rule kind, the right part is the value. If the right part is empty, the line is ignored. - If the line contains **`:`**, the **first** colon splits the line: the left part (trimmed) selects the rule kind, the right part is the value. If the right part is empty, the line is ignored.
- If there is **no** colon, the whole trimmed line is one “match-any” value (see below). - If there is **no** colon, the whole trimmed line is one “match-any” value (see below).
### Rule kinds (left part before the first `:`) ### Scope prefix (at the start of the line, before the rule type)
| Prefix | Events |
| --- | --- |
| *(none)* | **4624**, **4625** |
| `4740:`, `lockout:` | **4740** |
| `all:`, `*:` | **4624**, **4625**, **4740** |
Example: `4740:user:svc_sync` — do not alert on lockout for that account.
### Rule kinds (left part before the first `:` after the scope prefix)
| Left part (case-insensitive regex fragments) | Event field | | Left part (case-insensitive regex fragments) | Event field |
| --- | --- | | --- | --- |
@@ -121,8 +141,8 @@ Explicit **User** / **Workstation** / **Ip** kinds only compare their respective
### Examples and deployment ### Examples and deployment
- See **`ignore.lst.example`** in the repo; copy it to the server as **`ignore.lst`** and edit. - See **`ignore.lst.example`** in the repo; copy it to the server as **`ignore.lst`** and edit.
- **`Deploy-LoginMonitor.ps1`** does **not** copy this file — rules are usually host-specific; create **`ignore.lst`** manually or via your configuration tooling. - **`Deploy-LoginMonitor.ps1`** does **not** copy or overwrite **`ignore.lst`** or **`login_monitor.settings.ps1`**; if settings are missing, Deploy creates them once from **`login_monitor.settings.example.ps1`** on the share.
## Keywords (for discovery) ## Keywords (for discovery)
`rdp`, `rd-gateway`, `rdp-gateway`, `rds`, `remote-desktop`, `windows-security-log`, `eventlog`, `event-id-4624`, `event-id-4625`, `event-id-302`, `event-id-303`, `powershell`, `telegram-bot`, `watchdog`, `gpo`, `netlogon`, `domain-deployment`, `windows-server`, `monitoring` `rdp`, `rd-gateway`, `rdp-gateway`, `rds`, `remote-desktop`, `windows-security-log`, `eventlog`, `event-id-4624`, `event-id-4625`, `event-id-4740`, `event-id-302`, `event-id-303`, `account-lockout`, `active-sync`, `exchange`, `iis`, `smtp`, `email`, `powershell`, `telegram-bot`, `watchdog`, `gpo`, `netlogon`, `domain-deployment`, `windows-server`, `monitoring`
+29
View File
@@ -0,0 +1,29 @@
<#
.SYNOPSIS
Graceful restart RDP Login Monitor без Stop-Process.
.DESCRIPTION
settings перечитать login_monitor.settings.ps1 в том же процессе PowerShell.
recycle корректно завершить монитор и запустить новый процесс (после обновления Login_Monitor.ps1).
.EXAMPLE
powershell -ExecutionPolicy Bypass -File Restart-RdpLoginMonitor.ps1
powershell -ExecutionPolicy Bypass -File Restart-RdpLoginMonitor.ps1 -Recycle
#>
[CmdletBinding()]
param(
[switch]$Recycle
)
$ErrorActionPreference = 'Stop'
$installRoot = [System.IO.Path]::GetFullPath("$env:ProgramData\RDP-login-monitor")
$monitorScript = Join-Path $installRoot 'Login_Monitor.ps1'
if (-not (Test-Path -LiteralPath $monitorScript)) {
Write-Error "Не найден: $monitorScript"
exit 1
}
$args = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $monitorScript, '-RequestRestart')
if ($Recycle) { $args += '-Recycle' }
& "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" @args
exit $LASTEXITCODE
+401
View File
@@ -0,0 +1,401 @@
<#
.SYNOPSIS
Клиент Security Alert Center для RDP-login-monitor.
.DESCRIPTION
Dot-source после login_monitor.settings.ps1 и функции Write-Log.
Ожидает: $UseSAC, $SacUrl, $SacApiKey, $ScriptVersion, $script:InstallRoot.
#>
function Write-SacLog {
param([string]$Message)
if (Get-Command Write-Log -ErrorAction SilentlyContinue) {
Write-Log $Message
} else {
Write-Host $Message
}
}
function Get-SacNormalizedMode {
$m = if ($null -ne $UseSAC) { [string]$UseSAC } else { 'off' }
return $m.Trim().ToLowerInvariant()
}
function Test-SacConfigured {
return (-not [string]::IsNullOrWhiteSpace($SacUrl)) -and (-not [string]::IsNullOrWhiteSpace($SacApiKey))
}
function Get-SacBaseUrl {
if ([string]::IsNullOrWhiteSpace($SacUrl)) { return $null }
$url = $SacUrl.Trim().TrimEnd('/')
if ($url -match '/api/v1/events$') {
$url = $url -replace '/api/v1/events$', ''
}
return $url.TrimEnd('/')
}
function Get-SacIngestUrl {
$base = Get-SacBaseUrl
if ([string]::IsNullOrWhiteSpace($base)) { return $null }
return "$base/api/v1/events"
}
function Get-SacSpoolDirResolved {
if (-not [string]::IsNullOrWhiteSpace($SacSpoolDir)) {
return $SacSpoolDir.Trim()
}
return (Join-Path $script:InstallRoot 'sac-spool')
}
function Get-SacAgentIdFileResolved {
if (-not [string]::IsNullOrWhiteSpace($SacAgentIdFile)) {
return $SacAgentIdFile.Trim()
}
return (Join-Path $script:InstallRoot 'agent_instance_id')
}
function Get-SacFailCountFileResolved {
return (Join-Path $script:InstallRoot 'sac-fail.count')
}
function Get-SacAgentInstanceId {
$idFile = Get-SacAgentIdFileResolved
$dir = Split-Path -Parent $idFile
if (-not (Test-Path -LiteralPath $dir)) {
New-Item -ItemType Directory -Path $dir -Force | Out-Null
}
if (Test-Path -LiteralPath $idFile) {
$existing = (Get-Content -LiteralPath $idFile -TotalCount 1 -ErrorAction SilentlyContinue)
if (-not [string]::IsNullOrWhiteSpace($existing)) {
return $existing.Trim()
}
}
$newId = [guid]::NewGuid().ToString()
try {
Set-Content -LiteralPath $idFile -Value $newId -Encoding UTF8 -NoNewline
} catch { }
return $newId
}
function Get-SacCategoryForType {
param([string]$EventType)
if ($EventType -match '^(ssh\.|auth\.|rdp\.)') { return 'auth' }
if ($EventType -match '^privilege\.') { return 'privilege' }
if ($EventType -match '^session\.') { return 'session' }
if ($EventType -match '^report\.') { return 'report' }
if ($EventType -match '^rdg\.') { return 'network' }
return 'agent'
}
function New-SacEventPayload {
param(
[Parameter(Mandatory = $true)][string]$EventType,
[Parameter(Mandatory = $true)][string]$Severity,
[Parameter(Mandatory = $true)][string]$Title,
[Parameter(Mandatory = $true)][string]$Summary,
[hashtable]$Details = $null
)
$payload = [ordered]@{
schema_version = '1.0'
event_id = [guid]::NewGuid().ToString()
occurred_at = (Get-Date).ToString('o')
source = [ordered]@{
product = 'rdp-login-monitor'
product_version = if ($ScriptVersion) { [string]$ScriptVersion } else { 'unknown' }
agent_instance_id = Get-SacAgentInstanceId
}
host = [ordered]@{
hostname = $env:COMPUTERNAME
os_family = 'windows'
}
category = (Get-SacCategoryForType -EventType $EventType)
type = $EventType
severity = $Severity
title = $Title
summary = $Summary
}
if ($null -ne $Details -and $Details.Count -gt 0) {
$payload.details = $Details
}
return $payload
}
function Get-SacFailCount {
$f = Get-SacFailCountFileResolved
if (-not (Test-Path -LiteralPath $f)) { return 0 }
$raw = (Get-Content -LiteralPath $f -TotalCount 1 -ErrorAction SilentlyContinue) -replace '\D', ''
if ([string]::IsNullOrWhiteSpace($raw)) { return 0 }
return [int]$raw
}
function Set-SacFailCount {
param([int]$Count)
$f = Get-SacFailCountFileResolved
$dir = Split-Path -Parent $f
if (-not (Test-Path -LiteralPath $dir)) {
New-Item -ItemType Directory -Path $dir -Force | Out-Null
}
Set-Content -LiteralPath $f -Value ([string]$Count) -Encoding UTF8 -NoNewline
}
function Reset-SacFailCount {
Set-SacFailCount -Count 0
}
function Test-SacShouldAttemptSend {
$mode = Get-SacNormalizedMode
if ($mode -ne 'fallback') { return $true }
$max = if ($SacFallbackFailures) { [int]$SacFallbackFailures } else { 5 }
$n = Get-SacFailCount
if ($n -lt $max) { return $true }
if (Test-SacHealth) {
Reset-SacFailCount
Write-SacLog 'SAC: /health OK, resuming POST (fallback)'
return $true
}
Write-SacLog "WARN: SAC fallback: skip POST ($n>=$max failures), local channels only"
return $false
}
function Invoke-SacTlsPrep {
if (-not $SacTlsSkipVerify) { return }
if (-not $script:SacTlsCallbackRegistered) {
[System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true }
$script:SacTlsCallbackRegistered = $true
}
}
function Test-SacHealth {
if (-not (Test-SacConfigured)) { return $false }
$base = Get-SacBaseUrl
if ([string]::IsNullOrWhiteSpace($base)) { return $false }
$timeout = if ($SacTimeoutSec) { [int]$SacTimeoutSec } else { 12 }
try {
Invoke-SacTlsPrep
$resp = Invoke-WebRequest -Uri "$base/health" -Method Get -UseBasicParsing -TimeoutSec $timeout
return ($resp.StatusCode -eq 200)
} catch {
return $false
}
}
function Write-SacSpoolFile {
param(
[string]$EventId,
[string]$JsonBody
)
$dir = Get-SacSpoolDirResolved
if (-not (Test-Path -LiteralPath $dir)) {
New-Item -ItemType Directory -Path $dir -Force | Out-Null
}
$path = Join-Path $dir "$EventId.json"
[System.IO.File]::WriteAllText($path, $JsonBody, (New-Object System.Text.UTF8Encoding $false))
}
function Remove-SacSpoolFile {
param([string]$EventId)
$path = Join-Path (Get-SacSpoolDirResolved) "$EventId.json"
if (Test-Path -LiteralPath $path) {
Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue
}
}
function Invoke-SacPostPayload {
param([string]$JsonBody)
if (-not (Test-SacConfigured)) { return $false }
if (-not (Test-SacShouldAttemptSend)) { return $false }
$ingest = Get-SacIngestUrl
if ([string]::IsNullOrWhiteSpace($ingest)) { return $false }
$obj = $JsonBody | ConvertFrom-Json
$eventId = [string]$obj.event_id
$eventType = [string]$obj.type
$timeout = if ($SacTimeoutSec) { [int]$SacTimeoutSec } else { 12 }
try {
Invoke-SacTlsPrep
$headers = @{
Authorization = "Bearer $SacApiKey"
'Content-Type' = 'application/json'
'Idempotency-Key' = $eventId
}
$resp = Invoke-WebRequest -Uri $ingest -Method Post -Headers $headers -Body $JsonBody -UseBasicParsing -TimeoutSec $timeout
if ($resp.StatusCode -eq 202) {
Remove-SacSpoolFile -EventId $eventId
Reset-SacFailCount
Write-SacLog "SAC: accepted event_id=$eventId type=$eventType"
return $true
}
$body = if ($resp.Content) { $resp.Content } else { '' }
Write-SacLog "WARN: SAC POST HTTP $($resp.StatusCode): $($body.Substring(0, [Math]::Min(200, $body.Length)))"
} catch {
$code = 0
if ($_.Exception.Response) {
$code = [int]$_.Exception.Response.StatusCode
}
$err = $_.Exception.Message
Write-SacLog "WARN: SAC POST HTTP ${code}: $err"
}
Write-SacSpoolFile -EventId $eventId -JsonBody $JsonBody
$max = if ($SacFallbackFailures) { [int]$SacFallbackFailures } else { 5 }
$n = Get-SacFailCount + 1
Set-SacFailCount -Count $n
if ($n -ge $max) {
Write-SacLog "WARN: SAC fallback: SAC_FALLBACK_FAILURES threshold ($max)"
}
return $false
}
function Send-SacEvent {
param(
[Parameter(Mandatory = $true)][string]$EventType,
[Parameter(Mandatory = $true)][string]$Severity,
[Parameter(Mandatory = $true)][string]$Title,
[Parameter(Mandatory = $true)][string]$Summary,
[hashtable]$Details = $null
)
if (-not (Test-SacConfigured)) {
Write-SacLog 'WARN: SAC not configured (SacUrl / SacApiKey)'
return $false
}
$payload = New-SacEventPayload -EventType $EventType -Severity $Severity -Title $Title -Summary $Summary -Details $Details
$json = $payload | ConvertTo-Json -Depth 8 -Compress
return (Invoke-SacPostPayload -JsonBody $json)
}
function Send-SacLocalChannels {
param(
[string]$TelegramMessage,
[string]$EmailSubject
)
if ([string]::IsNullOrWhiteSpace($TelegramMessage)) { return $false }
$channels = @(Get-NotifyOrderChannels)
if ($channels.Count -eq 0) { return $false }
$anyOk = $false
foreach ($ch in $channels) {
$ok = switch ($ch) {
'telegram' { Send-TelegramMessage -Message $TelegramMessage }
'email' { Send-EmailNotification -Message $TelegramMessage -Subject $EmailSubject }
default { $false }
}
if ($ok) { $anyOk = $true }
}
return $anyOk
}
function Test-SacHeartbeatOnlyEventType {
param([string]$EventType)
return ($EventType -eq 'agent.heartbeat')
}
function Send-NotifyOrSac {
param(
[Parameter(Mandatory = $true)][string]$EventType,
[Parameter(Mandatory = $true)][string]$Severity,
[Parameter(Mandatory = $true)][string]$Title,
[Parameter(Mandatory = $true)][string]$Summary,
[string]$TelegramMessage = '',
[string]$EmailSubject = 'RDP Login Monitor',
[hashtable]$Details = $null
)
if ([string]::IsNullOrWhiteSpace($TelegramMessage)) {
$TelegramMessage = $Summary
}
$mode = Get-SacNormalizedMode
# Периодический heartbeat — только SAC (UI), без Telegram/email в любом режиме.
if (Test-SacHeartbeatOnlyEventType -EventType $EventType) {
if ($mode -eq 'off') {
return $false
}
return (Send-SacEvent -EventType $EventType -Severity $Severity -Title $Title -Summary $Summary -Details $Details)
}
switch ($mode) {
'off' {
return (Send-SacLocalChannels -TelegramMessage $TelegramMessage -EmailSubject $EmailSubject)
}
'exclusive' {
return (Send-SacEvent -EventType $EventType -Severity $Severity -Title $Title -Summary $Summary -Details $Details)
}
'dual' {
Send-SacEvent -EventType $EventType -Severity $Severity -Title $Title -Summary $Summary -Details $Details | Out-Null
return (Send-SacLocalChannels -TelegramMessage $TelegramMessage -EmailSubject $EmailSubject)
}
'fallback' {
if (Send-SacEvent -EventType $EventType -Severity $Severity -Title $Title -Summary $Summary -Details $Details) {
return $true
}
return (Send-SacLocalChannels -TelegramMessage $TelegramMessage -EmailSubject $EmailSubject)
}
default {
Write-SacLog "WARN: unknown UseSAC=$mode, local channels only"
return (Send-SacLocalChannels -TelegramMessage $TelegramMessage -EmailSubject $EmailSubject)
}
}
}
function Invoke-SacFlushSpool {
param([int]$MaxFiles = 20)
$mode = Get-SacNormalizedMode
if ($mode -eq 'off') { return }
if (-not (Test-SacConfigured)) { return }
$dir = Get-SacSpoolDirResolved
if (-not (Test-Path -LiteralPath $dir)) { return }
$files = @(Get-ChildItem -LiteralPath $dir -Filter '*.json' -File -ErrorAction SilentlyContinue | Sort-Object LastWriteTime)
$count = 0
foreach ($f in $files) {
$count++
if ($count -gt $MaxFiles) { break }
try {
$json = [System.IO.File]::ReadAllText($f.FullName)
Invoke-SacPostPayload -JsonBody $json | Out-Null
} catch { }
}
}
function Test-SacConnection {
Write-Host 'SAC check (rdp-login-monitor)'
Write-Host "UseSAC=$(Get-SacNormalizedMode)"
switch (Get-SacNormalizedMode) {
'exclusive' { Write-Host 'Mode exclusive: SAC only' }
'dual' { Write-Host 'Mode dual: SAC + local channels' }
'fallback' { Write-Host 'Mode fallback: SAC, then local on failure' }
}
Write-Host "SacUrl=$SacUrl"
if (Test-SacConfigured) {
Write-Host "SAC ingest URL=$(Get-SacIngestUrl)"
}
if (-not (Test-SacConfigured)) {
Write-Error 'SAC: SacUrl or SacApiKey missing'
return 1
}
if (Test-SacHealth) {
Write-Host 'SAC health: OK'
} else {
Write-Error 'SAC health: FAIL'
return 1
}
if (Send-SacEvent -EventType 'agent.test' -Severity 'info' -Title 'SAC test' -Summary 'rdp-login-monitor CheckSac') {
Write-Host 'SAC ingest agent.test: OK (expected HTTP 202)'
return 0
}
Write-Error 'SAC ingest agent.test: FAIL'
return 1
}
+54
View File
@@ -0,0 +1,54 @@
<#
.SYNOPSIS
Пример локальных настроек Exchange-MailSecurity.ps1
.DESCRIPTION
Скопируйте в C:\ProgramData\RDP-login-monitor\exchange_monitor.settings.ps1
и задайте секреты / whitelist. Файл не деплоится с шары автоматически.
#>
# Telegram (или DPAPI Base64 с Encrypt-DpapiForRdpMonitor.ps1)
$TelegramBotToken = ''
$TelegramChatID = ''
# $TelegramBotTokenProtectedB64 = ''
# $TelegramChatIDProtectedB64 = ''
# SMTP (опционально)
# $MailSmtpHost = 'smtp.example.com'
# $MailFrom = 'monitor@example.com'
# $MailTo = 'admin@example.com'
# $NotifyOrder = 'telegram,email'
# Внешние домены-партнёры (не считать угрозой)
$ExternalDomainWhitelist = @(
# 'partner-bank.ru'
)
# Очереди
$QueueMessageCountThreshold = 150
# --- Пилот VIP (рекомендуется для первого запуска) ---
# $VipMailboxesOnly = $true
# $VipMailboxes = @(
# 'director@kalinamall.ru',
# 'cfo@kalinamall.ru'
# )
# $VipMailboxPatterns = @(
# '*@kalinamall.ru' # опционально: все ящики домена из Get-Mailbox
# )
# Первый ночной скан: не слать сотни алертов по уже существующим пересылкам
# $SuppressAlertsOnFirstBaselineRun = $true # по умолчанию в скрипте уже $true
# Отключённые Inbox rules с внешней пересылкой (важность «Средняя»)
# $ScanDisabledInboxRulesWithExternalForward = $true
# Сводка в TG/Email после каждого скана
# $SendInboxScanSummary = $true
# Удалённый EMS (если скрипт не на Exchange)
# $ExchangeServerFqdn = 'fifth.kalinamall.ru'
# Не сканировать Inbox rules (битое хранилище правил / Watson на Get-InboxRule)
# $SkipInboxScanMailboxes = @(
# 'k.selezneva@kalinamall.ru'
# )
+20 -14
View File
@@ -2,23 +2,29 @@
# C:\ProgramData\RDP-login-monitor\ignore.lst # C:\ProgramData\RDP-login-monitor\ignore.lst
# #
# Каждая непустая строка — одно правило. Строки с # или ; в начале — комментарии. # Каждая непустая строка — одно правило. Строки с # или ; в начале — комментарии.
# Подавляет только уведомления Security 4624/4625 (не RD Gateway, не RCM 1149).
# #
# Форматы: # Область действия (префикс в начале строки, необязателен):
# (по умолчанию) — только Security 4624/4625
# 4740: — только блокировка учётной записи (4740); для IP — любой IP из IIS
# all: — и 4624/4625, и 4740
#
# Форматы правила (после префикса области):
# user:domain\user # user:domain\user
# user:user # user:user
# workstation:IVANOV # workstation:IVANOV (не для 4740)
# ip:111.222.333.444 # ip:111.222.333.444
# Можно вставить «как в Telegram» (берётся значение после первого «:»):
# 👤 Пользователь: user
# 🖥️ Рабочая станция (клиент из события): IVANOV
# 🌐 IP адрес: 111.222.333.444
# #
# Строка без префикса: # Строка без префикса типа:
# IVANOV — совпадение с именем рабочей станции ИЛИ с пользователем (sam) ИЛИ с IP (если строка — IPv4) # IVANOV — рабочая станция / пользователь / IP (IPv4)
# 111.222.333.444только IP (в реальной конфигурации укажите действительный IPv4 клиента) # domain\user пользователь
# domain\user — пользователь целиком
# user:domain\user # --- только входы 4624/4625 ---
# workstation:IVANOV # user:domain\service_account
# ip:111.222.333.444 # ip:192.168.1.100
# --- только блокировки 4740 ---
# 4740:user:test.user
# 4740:ip:203.0.113.50
# --- все перечисленные события ---
# all:user:domain\noise_account
+53
View File
@@ -0,0 +1,53 @@
<#
.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) ---
# Репозиторий git.kalinamall.ru — доверенный; значения по умолчанию для домена.
$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 = ''
# --- Security Alert Center (SAC) ---
# off | exclusive | dual | fallback — см. security-alert-center/docs/agent-integration.md
$UseSAC = 'dual'
$SacUrl = 'https://sac.kalinamall.ru'
$SacApiKey = 'sac_UkOsAT3UWiQS54KK5OJPBDCSucysQDrKFju28wmYiz8'
# $SacSpoolDir = 'C:\ProgramData\RDP-login-monitor\sac-spool'
# $SacTimeoutSec = 12
# $SacTlsSkipVerify = $false
# $SacFallbackFailures = 5
# Проверка: powershell -File Login_Monitor.ps1 -CheckSac
# --- Узкое исключение шумовых сетевых логонов (LogonType=3, Advapi) ---
$IgnoreAdvapiNetworkLogonSourceIps = @(
'192.168.160.57'
)
# --- Блокировка учётной записи 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
+170
View File
@@ -0,0 +1,170 @@
<#
.SYNOPSIS
Obnovlyaet klon RDP-login-monitor s git.kalinamall.ru i kopiruet dist na NETLOGON.
.DESCRIPTION
Dlya servera publikatsii (napr. DC3). Ne trebuet imenovaniya remote: pri otsutstvii
dobavlyaetsya origin ili vypolnyaetsya git pull po URL.
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'
)
$ErrorActionPreference = 'Stop'
$DistFiles = @(
'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'
)
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
$out = & git @Arguments 2>&1
$code = $LASTEXITCODE
} finally {
Pop-Location
$ErrorActionPreference = $prevEap
}
foreach ($line in @($out)) {
if ($null -ne $line -and "$line".Length -gt 0) {
Write-UpdateLog "git: $line"
}
}
if ($code -ne 0) {
throw ("git {0} failed (exit {1})" -f ($Arguments -join ' '), $code)
}
return @($out)
}
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 -gt 0) {
if ('origin' -in $names) { return 'origin' }
return $names[0]
}
return $null
}
function Ensure-GitOriginRemote {
$name = Get-ConfiguredGitRemoteName
if ($null -ne $name) {
Write-UpdateLog "Using remote: $name"
return $name
}
Write-UpdateLog "No remotes; adding origin -> $GitUrl"
Invoke-GitCommand -Arguments @('remote', 'add', 'origin', $GitUrl)
return 'origin'
}
function Update-Repository {
Ensure-GitRepository
$remote = Ensure-GitOriginRemote
Invoke-GitCommand -Arguments @('fetch', $remote, $GitBranch)
Invoke-GitCommand -Arguments @('pull', $remote, $GitBranch)
$null = Invoke-GitCommand -Arguments @('rev-parse', '--short', 'HEAD')
}
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"
}
}
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"
}
}
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 @@
1.4.3 1.2.6-SAC