Compare commits

..

104 Commits

Author SHA1 Message Date
PTah 038363c5c7 fix: UTF-8 BOM for PS 5.1 script parsing (2.1.15-SAC)
Avoid mojibake/parser errors when running Deploy and helpers without BOM on Windows PowerShell 5.1.
2026-07-14 20:04:42 +10:00
PTah f7b3597206 fix: qwinsta username fallback when RCM 1149 user empty (2.1.14-SAC)
On some Win10 Pro hosts EventLog 1149 has blank Param1/Param2; resolve the sole RDP session user from qwinsta before emitting Sac/notify events.
2026-07-14 10:47:30 +10:00
PTah a746a82f2a fix: parse RCM 1149 UserData/EventXML for RDG logins (2.1.13-SAC)
1149 stores username in Param1/Param2 under UserData, not EventData — RDG sessions on
target PCs were skipped with empty User and never reached SAC as rdp.login.success.
2026-07-13 19:36:56 +10:00
PTah 6d5dfbbeed fix: rdp.session.logoff only on workstations, not DC/Exchange (2.1.12-SAC)
4634/4647 on servers with LogonType 3 flooded SAC with Kerberos/LDAP session ends, not RDP logoffs.
2026-07-13 17:36:31 +10:00
PTah 23fa83ddd6 feat: send rdp.session.logoff on Security 4634/4647 (2.1.11-SAC)
Direct LAN RDP logout is reported to SAC with the same workstation filters as login (LogonType 10), dedup against paired 4647/4634, and ignore.lst logoff scope.
2026-07-13 17:10:24 +10:00
PTah ac2384a479 fix(scripts): GitHub sanitize rewrites kalinamall doc URLs reliably 2026-07-07 20:22:22 +10:00
PTah e21fae2ae5 fix: audit hardening 2.1.10-SAC — gitignore, SAC TLS warn, Exchange safety
- Extend .gitignore for local settings, logs, spool, backups
- Log CRITICAL when SacTlsSkipVerify is enabled (Login_Monitor + Sac-Client)
- Exchange-MailSecurity: -WhatIf dry-run, scan safety warnings, v1.6.8
- Remove legacy Watchdog_RDP_Monitor.ps1 and Install-ScheduledTasks.ps1
- Add scripts for GitHub sanitize mirror push workflow
2026-07-07 20:20:00 +10:00
PTah 6d52a34a37 fix(scripts): papatramp Git URLs use PapaTramp org, not PTah
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 17:49:19 +10:00
PTah f033d5bd87 fix: daily report без локального Telegram при fallback SAC (2.1.9-SAC)
Аналог ssh-monitor: report.daily.* только SAC/spool, timeout 45s,
spool flush приоритет отчётам (50 файлов/цикл).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 12:00:36 +10:00
PTah f2bcc6c7df fix: restore production secrets on kalinamall and repair placeholder settings (2.1.8-SAC)
Real SAC/Telegram tokens and org paths back in example and NETLOGON publish script. Deploy now treats sac.example.com and sac_CHANGE_ME as unconfigured and overwrites from example.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 14:32:44 +10:00
PTah 5708e42c66 fix: dedup RDG 302 notify by user+target+external IP (2.1.7-SAC)
Suppress duplicate SAC alerts when Windows logs two RD Gateway 302 events
within 90s for the same user, target host, and external client IP.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 10:03:26 +10:00
PTah 8a937b8f17 fix: quarantine corrupt SAC spool files, flush newest first (2.1.6-SAC)
Reject null-padded spool payloads to rejected/, read UTF-16 spool, process recent events first, and raise flush batch to 20 so daily reports are not blocked by legacy junk.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 09:09:54 +10:00
PTah ccd50a085d fix: RCM 1149 silent drop on RDS servers (2.1.5-SAC)
Should-IgnoreEvent rejected every 1149 because ComputerName is always '-'; add persistent poll cursor, skip logging, and Diagnose on NETLOGON publish list.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 14:29:02 +10:00
PTah 55d58b25a3 docs: sync README version lines with 2.1.4-SAC
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 14:14:43 +10:00
PTah 4b8e3313ae docs: document -NetlogonDest UNC path for update-rdp-monitor.ps1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 09:21:17 +10:00
PTah ab53d47329 feat: RCM 1149 on RDS servers and login dedup (2.1.4-SAC)
Enable Rcm1149 monitoring on servers when RCM journal is available; dedup 1149 notifications like 4624.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 15:01:09 +10:00
PTah eecc0e1619 docs(deploy): clarify DailyReportEnabled comment in settings patch
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 14:41:53 +10:00
PTah 128dd278f6 feat(deploy): sync ServerIPv4 hint and DailyReportEnabled on upgrade (2.1.3-SAC)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 10:01:16 +10:00
PTah 8cefba1952 Merge branch 'main' of ssh://git.kalinamall.ru:2222/PapaTramp/RDP-login-monitor 2026-06-22 09:04:36 +10:00
PTah b4dd593160 docs: SAC WinRM update path without NETLOGON
Добавлен раздел об обновлении RDP через SAC: zip с сервера, staging и Deploy-LoginMonitor.ps1.
2026-06-20 20:03:59 +10:00
PTah f3a0adb7b8 fix: remove UTF-8 BOM from PowerShell entry scripts 2026-06-20 19:07:52 +10:00
PTah a0b473b4d7 docs: concise README for 2.1.2-SAC capabilities 2026-06-20 19:02:59 +10:00
PTah 2c1724fd90 fix: SAC poll URL interpolation in Sac-Client (2.1.2-SAC) 2026-06-19 23:49:44 +10:00
PTah a9ca50bc15 fix: default HeartbeatStaleAlertMultiplier (2.1.1-SAC)
Startup notification failed when settings lacked the variable; default 2 in script and deploy bootstrap.
2026-06-19 23:47:48 +10:00
PTah fb50fb3ea7 chore: bump version to 2.1.0-SAC 2026-06-19 23:42:08 +10:00
PTah 5d2b90779c feat: SAC agent command poll qwinsta/logoff (2.0.39-SAC)
Poll GET /api/v1/agent/commands every SacCommandPollIntervalSec;
execute qwinsta/logoff with run_as from SAC; POST result.
2026-06-19 23:37:58 +10:00
PTah afa80d169f chore(github): keep sanitized settings on public main
Production tokens and NETLOGON paths live on kalinamall/papatramp only. Use scripts/Push-PrivateMirror.ps1 after feature pushes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 11:12:59 +10:00
PTah 7f38c00094 fix: restore production secrets and paths for private git
Real Telegram/SAC tokens, NETLOGON and org hostnames back on kalinamall. GitHub main keeps placeholders; use scripts/Push-PrivateMirror.ps1 after public pushes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 11:12:29 +10:00
PTah c12f169189 merge: sync remotes; keep deploy StartupRebootDetectMinutes bootstrap
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 11:04:04 +10:00
PTah 37fdeb44c5 feat: deploy bootstrap StartupRebootDetectMinutes in agent settings
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 11:02:50 +10:00
PTah 746f9e23b2 feat: detect OS reboot on startup via System log and LastBootUpTime
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 10:42:40 +10:00
PTah 35e25063e3 chore(github): generic NETLOGON paths, remove mirror scripts
Replace \\B26\\NETLOGON with \\dc.contoso.local\\NETLOGON.
Remove scripts/ mirror tools and tools/Push-KalinamallOnly.ps1.
Sanitize login_monitor.settings.example (placeholders, no secrets).
Simplify update-rdp-monitor.ps1 to use origin remote only.
Keep dev tooling under tools/ only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 11:57:35 +10:00
PTah 7466254401 chore(docs): sync repository URLs for kalinamall mirror 2026-06-16 11:38:26 +10:00
PTah 8d17a0f560 chore(docs): GitHub default clone URL, remove .cursor from git
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 11:37:48 +10:00
PTah ae86cb3ea7 feat: persistent Security poll cursor with lookback replay (2.0.36-SAC)
Save last_security_poll.txt between runs and replay Security events up to SecurityEventsLookbackMinutes on startup so slow boot or late agent start does not miss RDP/WinRM-related logons.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 11:37:48 +10:00
PTah c146bb119a chore(docs): GitHub default clone URL, remove .cursor from git
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 11:36:51 +10:00
PTah 1fcd5cb5cf feat: persistent Security poll cursor with lookback replay (2.0.36-SAC)
Save last_security_poll.txt between runs and replay Security events up to SecurityEventsLookbackMinutes on startup so slow boot or late agent start does not miss RDP/WinRM-related logons.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 09:40:15 +10:00
PTah f48aee1d93 feat: persistent Security poll cursor with lookback replay (2.0.36-SAC)
Save last_security_poll.txt between runs and replay Security events up to SecurityEventsLookbackMinutes on startup so slow boot or late agent start does not miss RDP/WinRM-related logons.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 09:39:43 +10:00
PTah d5db38e2ae feat: deploy дописывает HeartbeatInterval при отсутствии в settings (2.0.35-SAC)
Ранний выход при совпадении версии не пропускает heartbeat; reload settings у работающего монитора.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 16:46:43 +10:00
PTah 91cde1668c merge: GitHub main + heartbeat on startup (2.0.34-SAC)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 11:52:15 +10:00
PTah 09f1b576bf feat: send agent.heartbeat on monitor startup (2.0.34-SAC)
SAC sees host online immediately after restart; periodic heartbeat every 4h unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 11:51:16 +10:00
PTah b6d293d21b fix: normalize ExecutionTimeLimit PT0S and load TaskQuery locally (2.0.33-SAC)
Treat Get-ScheduledTask limit strings as unlimited, prefer ProgramData for TaskQuery import, and copy UNC modules to temp before dot-source.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 15:27:47 +10:00
PTah 6ee5fdcbf2 fix: normalize ExecutionTimeLimit PT0S and load TaskQuery locally (2.0.33-SAC)
Treat Get-ScheduledTask limit strings as unlimited, prefer ProgramData for TaskQuery import, and copy UNC modules to temp before dot-source.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 15:27:38 +10:00
PTah 0da1d0f2ac fix: deploy TaskQuery script scope (2.0.32-SAC)
Publish TaskQuery helpers to script scope after import so ExecutionTimeLimit checks work from nested deploy callers; add RDP_DEPLOY_FUNCTIONS_ONLY early exit for isolated function loading.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 15:23:59 +10:00
PTah 5e9939724c fix: deploy TaskQuery script scope and internal autotests (2.0.32-SAC)
Publish TaskQuery helpers to script scope after import so deploy checks work from nested callers; add kalinamall-only smoke tests and RDP_DEPLOY_FUNCTIONS_ONLY hook.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 15:15:42 +10:00
PTah 31ebd5d653 fix: deploy task limit check without TaskQuery helper deps (2.0.31-SAC)
Deploy uses Get-RdpMonitorScheduledTaskExecutionTimeLimitResolved only; no call to Test-RdpMonitorScheduledTaskNeedsUnlimitedExecutionTimeLimit from external module.
2026-06-11 15:05:02 +10:00
PTah 5c23ac5f36 fix: deploy TaskQuery init must load Resolved helper (2.0.30-SAC)
Verify TaskQuery module by Get-RdpMonitorScheduledTaskExecutionTimeLimitResolved, prefer share path over stale InstallRoot, and guard verification when init fails.
2026-06-11 15:02:08 +10:00
PTah 6fa940dd43 fix: SendDeploySacNotice StrictMode and inline deploy task verify (2.0.29-SAC)
Safe ServerDisplayName access for SAC deploy notice; deploy falls back to built-in schtasks XML check when TaskQuery file is missing on NETLOGON.
2026-06-11 14:59:17 +10:00
PTah 463a3ef08c fix: deploy task verify via schtasks XML and notify SAC on update (2.0.28-SAC)
Stop false redeploy loops when Get-ScheduledTask fails, push agent version to SAC immediately after deploy, and skip redundant schtasks /Run during task maintenance.
2026-06-11 14:47:49 +10:00
PTah 399597c8a1 fix: sync HeartbeatInterval on every deploy, not only version upgrade
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 09:26:03 +10:00
PTah 365a7113cf feat: heartbeat 4h and deploy rewrites settings (2.0.27-SAC)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 17:25:20 +10:00
PTah 01d7525c4a fix(deploy): escape TaskName in log string (PowerShell parser); 2.0.25-SAC
${TaskName}: after colon was parsed as drive scope — deploy failed at parse time.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 11:51:06 +10:00
PTah 1d30ca2284 fix: RDP-Login-Monitor scheduled task unlimited ExecutionTimeLimit (2.0.24-SAC)
Default PT72H caused Task Scheduler to stop the monitor after 72 hours.
Register main task with PT0S, re-register on deploy and Ensure when limit is set.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 11:47:46 +10:00
PTah 80b2337d61 fix(winrm): Exchange strict mode to suppress false 91/4624 alerts
On Exchange role: require user in WinRM Event 91 EventData; correlate 4624 only when LogonProcess is WinRM. Prevents Outlook/LT3 false positives. Version 2.0.23-SAC.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 11:07:21 +10:00
PTah fe2e6d624f fix: inventory JSON without JavaScriptSerializer WARN (2.0.22-SAC)
Use ConvertTo-Json after CIM-safe Convert-AnyToJsonSerializable; pre-sanitize inventory details.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 10:00:11 +10:00
PTah 82c63fb9a8 feat: host inventory to SAC every 12h (GetInventory)
Collect CPU/RAM/disks/GPU/OS via agent.inventory; setting $GetInventory (default true); deploy adds missing setting.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 09:53:03 +10:00
PTah bd83dc84dc fix: StrictMode-safe File Share audit init (UseGuid key)
Enable-SecurityAudit crashed on init when checking RU/EN File Share attempts because accessing UseGuid on a hashtable without that key throws under Set-StrictMode -Version Latest.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 10:20:17 +10:00
PTah afded8a495 docs: README highlights table and 2.0.19-SAC admin share 5140
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 10:17:11 +10:00
PTah 5fc5f2a9ad feat: monitor admin share access via Security 5140 C$/ADMIN$ (2.0.19-SAC)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 10:14:14 +10:00
PTah e3149785dc fix: daily report survives empty quser sessions (2.0.18-SAC)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 09:32:43 +10:00
PTah 6384a45395 fix: recognize auditpol RU Success+Failure as otказ not only sboy (2.0.17-SAC)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 15:08:20 +10:00
PTah 0acd591c83 fix: PS 5.1 empty SkipEntries breaks monitor loop (2.0.16-SAC)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 15:06:35 +10:00
PTah fa5248d501 fix: Sac-Client splat syntax for PS 5.1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 11:45:49 +10:00
PTah f08a8a8561 fix: allow null SacOccurredAt on lifecycle notifications (2.0.15-SAC)
PS 5.1 rejects binding null to [datetime]; use optional splatting for SAC occurred_at.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 11:45:35 +10:00
PTah 550cea9759 fix: RD Gateway startup replay, 1226 benign, SAC occurred_at (2.0.14-SAC)
Persist RD Gateway poll cursor to avoid replaying 302/303 on monitor restart;
treat ErrorCode 1226 as normal disconnect; pass event TimeCreated to SAC ingest.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 11:42:03 +10:00
PTah e00513f050 feat: deploy patch for MaxBackupDays default (2.0.13-SAC)
Add deploy-time patch when login_monitor.settings.ps1 lacks MaxBackupDays,
enable the setting in example, and remove duplicate default in Login_Monitor.ps1.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 11:25:21 +10:00
PTah 22ac7a32c7 feat: shutdown diagnostics and log backup retention (2.0.12-SAC)
- Log phase, uptime, batch sizes and shutdown path on unexpected stop

- PipelineStopped sends SAC alert with diagnostics; fix StopNotificationSent on settings reload

- Summarize large Skip 4624 batches; Remove-LogBackupsBeyondRetention uses MaxBackupDays from settings

- Purge LoginLog_*.bak on rotation, daily rotation check and startup

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 11:07:03 +10:00
PTah 92773948a7 fix: apply login_monitor.settings.ps1 to script scope (2.0.10-SAC)
2.0.9 dot-sourced settings inside Import-LoginMonitorSettingsFile left UseSAC/Telegram in function scope; monitor ran with defaults off and no SAC ingest.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 10:33:27 +10:00
PTah 7b83a19db0 fix: InstallTasks fails when settings WinRM lines lack dollar sign (2.0.9-SAC)
Defer login_monitor.settings.ps1 dot-source until after -InstallTasks so broken WinRM assignments cannot abort task registration. Deploy repairs settings lines missing $ before variable names.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 10:14:17 +10:00
PTah 33367b05a8 fix: parse RD Gateway EventInfo XML and skip ephemeral 303 alerts
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 10:07:51 +10:00
PTah 94d12e0b5f chore: sync Cursor rules from CursorRules (version bump RDP/SSH split)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 09:31:56 +10:00
PTah 4b8ff95344 chore: add Cursor rule to bump ScriptVersion and version.txt on script changes
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 09:29:23 +10:00
PTah 47f95ba186 chore: bump release to 2.0.7-SAC
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 09:17:28 +10:00
PTah 335768915b feat: deploy UseSAC fallback by default and prevent duplicate daily reports
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 09:15:22 +10:00
PTah 5e5bc86e1b docs: add one-command quick start for first install
Put NETLOGON deploy flow at the top of README so initial setup is straightforward: run one command and verify with log tail.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 13:28:28 +10:00
PTah 42d6af83f2 docs: add 5-minute WinRM stabilization checklist
Document quick post-deploy validation for Enter-PSSession -> WinRM 91 -> SAC event flow and minimal noise-suppression guidance.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 13:26:32 +10:00
PTah 52e9e62ede docs: refresh README for WinRM and current SAC integration state
Update current RDP monitor version and document recent WinRM parsing, deploy self-heal, and skip reason diagnostics behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 13:25:16 +10:00
PTah fae185421a fix: harden WinRM local IP detection and bump to 2.0.6-SAC
Normalize WinRM source IP candidate before loopback/link-local checks and use explicit -Ip binding in WinRM ignore reason path to avoid false local-or-linklocal-ip skips.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 13:20:38 +10:00
PTah afc7bf9dca fix: log exact WinRM skip reason and bump to 2.0.5-SAC
Add Get-WinRmIgnoreReason to expose precise built-in/ignore decision path (empty user, excluded pattern, machine account, local IP, ignore-list match) for WinRM 91 diagnostics.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 13:17:48 +10:00
PTah ac095fe1a0 fix: parse WinRM 91 user/ip correctly and bump to 2.0.4-SAC
Improve WinRM Operational event parsing to extract username and clientIP from EventData/Message fallback, preventing false Skip for valid Enter-PSSession events.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 13:05:14 +10:00
PTah 81323e8596 chore: bump RDP monitor version to 2.0.3-SAC
Increase ScriptVersion and version.txt after deploy-side reliability updates affecting WinRM monitoring behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 12:58:40 +10:00
PTah 1eedfc736f fix: enforce WinRM inbound block and enable Operational log in deploy
Deploy now ensures required WinRM inbound settings exist in login_monitor.settings.ps1 and attempts to enable Microsoft-Windows-WinRM/Operational via wevtutil when disabled.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 12:55:05 +10:00
PTah d54d04623f fix: PS 5.1 Sac-Client JSON types without uint alias 2.0.2-SAC
Replace [uint]/[ulong] -is checks with GetType().IsPrimitive for Windows PowerShell 5.1 on domain hosts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 09:13:19 +10:00
PTah 084494cfa8 fix: SAC JSON serialize daily report without PSMethod cycle 2.0.1-SAC
Convert-AnyToJsonSerializable returns plain arrays and skips PSMethod/ScriptBlock instead of Generic.List objects that broke JavaScriptSerializer.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 09:08:14 +10:00
PTah 1a1467f910 chore: bump release to 2.0.0-SAC
Major version line so Deploy upgrades agents stuck on 1.6.x and older 1.2.x-SAC builds from NETLOGON.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 09:03:48 +10:00
PTah 543369832a fix: restore RD Gateway 302/303 polling and SAC ingest 1.2.35-SAC
Fix poll cursor skipping events at time boundary, add 60min lookback on start, parse RU gateway messages, and map 303 disconnect to rdg.connection.disconnected.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 08:14:37 +10:00
PTah f970e284ac fix: stronger 4624 login dedup and logon type label 1.2.34-SAC
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 19:14:05 +10:00
PTah 6a9b2b70dc fix: use full path to schtasks.exe in deploy post-update start 1.2.33-SAC
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 19:03:28 +10:00
PTah 2118d4a4a0 fix: hide redundant workstation line in RDP login alerts 1.2.32-SAC
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 18:55:11 +10:00
PTah 78f241df32 feat: add Diagnose-RdpLoginMonitor.ps1 for post-login troubleshooting
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 18:40:10 +10:00
PTah e1ae9834a9 feat: single deploy startup, stop mode, notification_body 1.2.31-SAC
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 18:11:18 +10:00
PTah a74c8df877 feat: lifecycle notifications with telegram_via and settings reload 1.2.30-SAC
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 12:25:50 +10:00
PTah 2395f536cd feat: notification source line in agent alerts (1.2.29-SAC)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 11:51:55 +10:00
PTah b56ad9c7e4 feat: daily report agent version line and per-line active users (1.2.28-SAC)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 11:28:29 +10:00
PTah e738d97c9c chore: release 1.2.27-SAC (deploy version compare + docs)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 11:18:58 +10:00
PTah 1fe8bd9f7d fix(deploy): version compare for -SAC labels; refresh deploy docs
Compare deployed_version.txt with full label (e.g. 1.2.26-SAC) so GPO deploy exits when up to date. Restructure deploy-rdp-login-monitor.md with mermaid flow, Exchange vs DomainMonitors split, and remove duplicated sections.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 11:13:33 +10:00
PTah bf04c8865c feat(deploy): auto-add Exchange WinRM/4624 noise settings on role detect (1.2.26-SAC)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 10:36:00 +10:00
PTah b010ad01eb fix: filter WinRM noise (HealthMailbox, machine accounts, loopback)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 10:31:30 +10:00
PTah 1ff8dd1a95 fix: init NotifyDedupCache under StrictMode (1.2.24-SAC)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 10:26:07 +10:00
PTah 724baa6c0a feat: monitor RDS Shadow Control and WinRM inbound (warning alerts, 1.2.23-SAC)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 10:20:57 +10:00
PTah 2bbf1face6 fix(deploy): patch DailyReportEnabled hint and invalid false assignment (1.2.22-SAC)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 17:50:17 +10:00
PTah d23e99a885 feat: unify Windows daily report with SAC layout; DailyReportEnabled toggle
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 17:18:02 +10:00
PTah 70f06790f6 docs: deploy adds commented ServerDisplayName hint in settings
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 15:57:35 +10:00
PTah c249451eba fix: deploy syncs Sac-Client bundle, SAC dual settings, and ServerDisplayName hint (1.2.20-SAC)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 15:55:16 +10:00
45 changed files with 6368 additions and 1048 deletions
-28
View File
@@ -1,28 +0,0 @@
---
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
@@ -1,13 +0,0 @@
---
description: Code generation syntax restrictions to minimize output tokens
globs: "*.{ts,js,py,cs}"
---
# Syntax Optimization
- **No JSDoc/Docstrings:** Do NOT write documentation, comments, JSDoc, or docstrings for generated functions unless explicitly asked.
- **No logs or prints:** Remove all `console.log`, `print()`, or debugging statements from the final code output.
- **Use concise syntax:**
- In JavaScript/TypeScript: Use arrow functions, optional chaining (`?.`), nullish coalescing (`??`), and destructuring.
- In Python: Use list comprehensions, dict comprehensions, and built-in functions.
- **Minimize imports:** Do not output the `import` statements section if the required packages are standard and already exist in the file.
-18
View File
@@ -1,18 +0,0 @@
---
description: High-density PowerShell script generation rules for minimal token usage
globs: "*.ps1, *.psm1"
---
# PowerShell Token Optimization
- **Use Short Aliases:** Use short aliases instead of full cmdlet names to drastically cut output tokens:
- Use `gc` instead of `Get-Content`
- Use `gci` instead of `Get-ChildItem`
- Use `%` instead of `ForEach-Object`
- Use `?` instead of `Where-Object`
- Use `measure` instead of `Measure-Object`
- **Pipeline Over Loops:** Prefer pipeline chains (`gci | % { ... }`) over multi-line `foreach ($item in $items) { ... }` blocks.
- **No Help/Comments:** Do not generate `.SYNOPSIS`, `.DESCRIPTION`, or comment-based help at the top of scripts.
- **Omit Parameter Names:** Drop explicit parameter names where positional arguments are clear (e.g., use `gc file.txt` instead of `Get-Content -Path file.txt`).
- **Silent Execution:** Do not add verbose logging, `Write-Host`, or `Write-Output` unless explicitly asked to create UI/logs.
- **Preserve CLI Arguments:** Do not duplicate full multi-line `yt-dlp` command-line arguments, format strings, or output templates if they are not the subject of the modifications. Use placeholders or variable references.
-14
View File
@@ -1,14 +0,0 @@
---
description: Ultra-dense Python code generation rules to save output tokens
globs: "*.py"
---
# Python Token Optimization
- **Use Syntactic Sugar:** Prioritize list comprehensions, dict comprehensions, and ternary operators (`x if condition else y`) to keep code on a single line.
- **Built-in Libraries First:** Use standard libraries (`pathlib`, `json`, `subprocess`, `asyncio`) instead of introducing heavy external dependencies unless already in `requirements.txt`.
- **Type Hinting:** Do NOT add type hints (`def func(x: int) -> str:`) unless the existing file strictly uses them. Type hints consume significant tokens.
- **No Format Duplication:** When modifying scripts (like video downloaders), provide only the modified function or class method. Never output the `if __name__ == "__main__":` block or argument parsing logic if they haven't changed.
- **No Docstrings:** Strictly forbid writing `"""docstrings"""` or `# comments` explaining the logic.
- **Preserve yt-dlp Options:** Never rewrite, duplicate, or expand the `ydl_opts` configuration dictionary or custom extraction options. If changes are unrelated to download options, use a placeholder comment like `# ... existing ydl_opts ...` instead of outputting the full dictionary block.
-22
View File
@@ -1,22 +0,0 @@
---
description: Global token-saving rules for ultra-concise communication and minimal context usage
globs: *
---
# Token-Saving Instructions
## Communication Strategy
- **Strictly no fluff:** Omit all greetings, pleasantries, apologies, and concluding remarks.
- **Direct answers only:** Start your response immediately with the solution, code block, or direct answer.
- **Extreme brevity:** Keep explanations under 2-3 sentences. Use bullet points instead of long paragraphs.
## Code Generation Guidelines
- **Do not restate existing code:** Never copy and paste parts of my existing file just to show where to insert changes.
- **Provide diffs only:** Show only the specific lines that need to be changed, added, or deleted. Use brief comments (`// ... existing code ...`) to show placement if necessary.
- **No boilerplate:** Do not generate setup code, imports, or boilerplate unless explicitly requested.
- **Single-line implementations:** Prefer concise, clean, short code syntax where readable (e.g., arrow functions, ternary operators).
## Code Review and Verification
- Do not explain why the code works unless asked.
- If the solution is simple, output *only* the code block and nothing else.
- If you need more information, ask a single, precise question. Do not list multiple hypotheticals.
-44
View File
@@ -1,44 +0,0 @@
---
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`).
+8
View File
@@ -0,0 +1,8 @@
.cursor/
tools/*.log
*.log
*.bak
Logs/
sac-spool/
login_monitor.settings.ps1
exchange_monitor.settings.ps1
+1318 -48
View File
File diff suppressed because it is too large Load Diff
+447
View File
@@ -0,0 +1,447 @@
<#
.SYNOPSIS
Диагностика RDP Login Monitor после входа по RDP (или при «тишине» в Telegram/SAC).
.DESCRIPTION
Собирает: процесс монитора, задачи планировщика, настройки (без секретов), хвост login_monitor.log,
Security 4624/4625/4778/4634, симуляцию фильтров монитора, SAC spool, сессии RDP.
Отчёт: C:\ProgramData\RDP-login-monitor\Logs\diagnose_YYYYMMDD_HHmmss.txt
.EXAMPLE
powershell.exe -NoProfile -ExecutionPolicy Bypass -File Diagnose-RdpLoginMonitor.ps1 -MinutesBack 15 -ExpectedUser jdoe
.NOTES
Рекомендуется запуск от администратора. Без прав Security-журнал может быть неполным.
#>
[CmdletBinding()]
param(
[string]$InstallRoot = "$env:ProgramData\RDP-login-monitor",
[int]$MinutesBack = 15,
[int]$MonitorLogTailLines = 120,
[string]$ExpectedUser = '',
[string]$OutputPath = ''
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Continue'
function Write-Section {
param([string]$Title)
$line = ('=' * 72)
"`n$line`n $Title`n$line`n"
}
function Redact-SettingsText {
param([string]$Text)
if ([string]::IsNullOrWhiteSpace($Text)) { return '(файл пуст или не прочитан)' }
$out = $Text
$out = [regex]::Replace($out, '(?m)^(\s*\$(?:TelegramBotToken|TelegramChatID|SacApiKey|MailSmtpPassword|TelegramBotTokenProtectedB64|TelegramChatIDProtectedB64|MailSmtpPasswordProtectedB64)\s*=).*', '${1}***REDACTED***')
return $out
}
function Get-EventDataMapFromEvent {
param($Event)
$map = @{}
try {
$xml = [xml]$Event.ToXml()
foreach ($d in $xml.Event.EventData.Data) {
$name = [string]$d.Name
if ([string]::IsNullOrWhiteSpace($name)) { continue }
$map[$name] = [string]$d.'#text'
}
} catch { }
return $map
}
function Get-LoginFieldsFromEvent {
param($Event)
$map = Get-EventDataMapFromEvent -Event $Event
$username = $map['TargetUserName']
if ([string]::IsNullOrWhiteSpace($username)) { $username = $map['AccountName'] }
if ([string]::IsNullOrWhiteSpace($username)) { $username = $map['UserName'] }
if ([string]::IsNullOrWhiteSpace($username)) { $username = '-' }
$computerName = $map['WorkstationName']
if ([string]::IsNullOrWhiteSpace($computerName)) { $computerName = $map['ComputerName'] }
if ([string]::IsNullOrWhiteSpace($computerName)) { $computerName = '-' }
$sourceIP = $map['IpAddress']
if ([string]::IsNullOrWhiteSpace($sourceIP)) { $sourceIP = $map['SourceNetworkAddress'] }
if ([string]::IsNullOrWhiteSpace($sourceIP)) { $sourceIP = '-' }
$logonType = 0
if ($map.ContainsKey('LogonType') -and $map['LogonType'] -match '(\d+)') {
$logonType = [int]$Matches[1]
}
$processName = $map['LogonProcessName']
if ([string]::IsNullOrWhiteSpace($processName)) { $processName = $map['AuthenticationPackageName'] }
if ([string]::IsNullOrWhiteSpace($processName)) { $processName = '-' }
[pscustomobject]@{
TimeCreated = $Event.TimeCreated
EventId = [int]$Event.Id
Username = $username.Trim()
ComputerName = $computerName.Trim()
SourceIP = $sourceIP.Trim()
LogonType = $logonType
ProcessName = $processName.Trim()
}
}
function Test-IsWorkstationOs {
try {
$pt = (Get-CimInstance Win32_OperatingSystem -ErrorAction Stop).ProductType
return ($pt -eq 1)
} catch {
return $false
}
}
function Get-MonitorVerdictFor4624 {
param(
$Fields,
[bool]$IsWorkstation
)
$reasons = [System.Collections.Generic.List[string]]::new()
if ($Fields.EventId -ne 4624) {
return [pscustomobject]@{ Verdict = 'N/A'; Reasons = @('не 4624') }
}
if ($IsWorkstation) {
$allowed = @(10)
$modeLabel = 'workstation LT10'
} else {
$allowed = @(2, 3, 10)
$modeLabel = 'server LT2/3/10'
}
if ($allowed -notcontains $Fields.LogonType) {
$reasons.Add("LogonType $($Fields.LogonType) not in $modeLabel")
}
$u = $Fields.Username
if ([string]::IsNullOrWhiteSpace($u) -or $u -eq '-') { $reasons.Add('пустой Username') }
if ($u -match '(?i)(\\)?DWM-\d+') { $reasons.Add('DWM-*') }
if ($u -match '(?i)(\\)?UMFD-\d+') { $reasons.Add('UMFD-*') }
if ($u -like '*$') { $reasons.Add('machine account ($)') }
if ($u -match '(?i)^(SYSTEM|LOCAL SERVICE|NETWORK SERVICE|ANONYMOUS LOGON)$') { $reasons.Add('служебная учётная запись') }
if ($Fields.SourceIP -eq '127.0.0.1' -or $Fields.SourceIP -like 'fe80:*' -or $Fields.SourceIP -eq '::1') {
$reasons.Add("локальный IP ($($Fields.SourceIP))")
}
if ($Fields.ComputerName -eq '-' -or $Fields.ComputerName -eq 'N/A') {
$reasons.Add('WorkstationName = - (встроенный фильтр монитора)')
}
if ($Fields.ProcessName -like '*NtLmSsp*') { $reasons.Add('Process NtLmSsp') }
if ($reasons.Count -eq 0) {
return [pscustomobject]@{ Verdict = 'WOULD_NOTIFY'; Reasons = @('проходит фильтры монитора (4624)') }
}
return [pscustomobject]@{ Verdict = 'WOULD_SKIP'; Reasons = $reasons }
}
function Get-ExternalCommandOutput {
param(
[string]$Label,
[scriptblock]$Block
)
$sb = New-Object System.Text.StringBuilder
[void]$sb.AppendLine("--- $Label ---")
try {
$out = & $Block 2>&1
if ($null -eq $out) {
[void]$sb.AppendLine('(нет вывода)')
} else {
foreach ($line in @($out)) {
[void]$sb.AppendLine([string]$line)
}
}
} catch {
[void]$sb.AppendLine("ERROR: $($_.Exception.Message)")
}
return $sb.ToString()
}
$startedAt = Get-Date
$isAdmin = $false
try {
$id = [Security.Principal.WindowsIdentity]::GetCurrent()
$p = New-Object Security.Principal.WindowsPrincipal($id)
$isAdmin = $p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
} catch { }
if (-not $OutputPath) {
$logDir = Join-Path $InstallRoot 'Logs'
if (-not (Test-Path -LiteralPath $logDir)) {
New-Item -ItemType Directory -Path $logDir -Force | Out-Null
}
$OutputPath = Join-Path $logDir ("diagnose_{0:yyyyMMdd_HHmmss}.txt" -f $startedAt)
}
$report = New-Object System.Text.StringBuilder
[void]$report.AppendLine('RDP Login Monitor — диагностический отчёт')
[void]$report.AppendLine("Сформирован: $($startedAt.ToString('yyyy-MM-dd HH:mm:ss'))")
[void]$report.AppendLine("Компьютер: $env:COMPUTERNAME")
[void]$report.AppendLine("Пользователь сеанса: $([Environment]::UserDomainName)\$([Environment]::UserName)")
[void]$report.AppendLine("Elevated (Admin): $isAdmin")
[void]$report.AppendLine("InstallRoot: $InstallRoot")
[void]$report.AppendLine("MinutesBack: $MinutesBack")
if ($ExpectedUser) { [void]$report.AppendLine("ExpectedUser: $ExpectedUser") }
[void]$report.AppendLine("ReportPath: $OutputPath")
[void]$report.Append((Write-Section '1. Процесс Login_Monitor.ps1'))
$procs = @(
Get-CimInstance Win32_Process -Filter "Name='powershell.exe' OR Name='pwsh.exe'" -ErrorAction SilentlyContinue |
Where-Object { $_.CommandLine -match 'Login_Monitor\.ps1' }
)
if ($procs.Count -eq 0) {
[void]$report.AppendLine('НЕ НАЙДЕН процесс Login_Monitor.ps1')
} else {
foreach ($proc in $procs) {
[void]$report.AppendLine("PID=$($proc.ProcessId) Start=$($proc.CreationDate)")
[void]$report.AppendLine(" $($proc.CommandLine)")
}
}
[void]$report.Append((Write-Section '2. Scheduled Tasks'))
foreach ($tn in @('RDP-Login-Monitor', 'RDP-Login-Monitor-Watchdog')) {
[void]$report.AppendLine("--- $tn ---")
$q = schtasks.exe /Query /TN $tn /FO LIST /V 2>&1
if ($LASTEXITCODE -ne 0) {
[void]$report.AppendLine(' (задача не найдена или ошибка запроса)')
} else {
foreach ($line in @($q)) { [void]$report.AppendLine(" $line") }
}
}
[void]$report.Append((Write-Section '3. Версии / deploy markers'))
foreach ($rel in @('version.txt', 'deployed_version.txt', 'deploy_last_update.txt', 'restart.request')) {
$fp = Join-Path $InstallRoot $rel
[void]$report.AppendLine("--- $rel ---")
if (Test-Path -LiteralPath $fp) {
Get-Content -LiteralPath $fp -ErrorAction SilentlyContinue | ForEach-Object {
[void]$report.AppendLine(" $_")
}
} else {
[void]$report.AppendLine(' (нет файла)')
}
}
$lmPath = Join-Path $InstallRoot 'Login_Monitor.ps1'
if (Test-Path -LiteralPath $lmPath) {
$verLine = Select-String -LiteralPath $lmPath -Pattern '^\$ScriptVersion\s*=' -ErrorAction SilentlyContinue | Select-Object -First 1
if ($verLine) {
[void]$report.AppendLine("Login_Monitor.ps1: $($verLine.Line.Trim())")
}
}
[void]$report.Append((Write-Section '4. login_monitor.settings.ps1 (redacted)'))
$settingsPath = Join-Path $InstallRoot 'login_monitor.settings.ps1'
if (Test-Path -LiteralPath $settingsPath) {
$raw = Get-Content -LiteralPath $settingsPath -Raw -ErrorAction SilentlyContinue
[void]$report.AppendLine(Redact-SettingsText -Text $raw)
} else {
[void]$report.AppendLine('Файл settings не найден')
}
[void]$report.AppendLine('')
[void]$report.AppendLine('--- ignore.lst ---')
$ignorePath = Join-Path $InstallRoot 'ignore.lst'
if (Test-Path -LiteralPath $ignorePath) {
Get-Content -LiteralPath $ignorePath | ForEach-Object { [void]$report.AppendLine(" $_") }
} else {
[void]$report.AppendLine(' (нет файла)')
}
[void]$report.Append((Write-Section '5. SAC spool / heartbeat'))
$spoolDir = Join-Path $InstallRoot 'sac-spool'
if (Test-Path -LiteralPath $spoolDir) {
$spoolFiles = @(Get-ChildItem -LiteralPath $spoolDir -Filter '*.json' -File -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending)
[void]$report.AppendLine("sac-spool: файлов $($spoolFiles.Count)")
$spoolFiles | Select-Object -First 5 | ForEach-Object {
[void]$report.AppendLine(" $($_.Name) $($_.LastWriteTime)")
}
} else {
[void]$report.AppendLine('sac-spool: каталог отсутствует')
}
$hbPath = Join-Path $InstallRoot 'heartbeat.txt'
if (Test-Path -LiteralPath $hbPath) {
[void]$report.AppendLine("heartbeat.txt: $(Get-Content -LiteralPath $hbPath -First 1)")
} else {
[void]$report.AppendLine('heartbeat.txt: нет')
}
[void]$report.Append((Write-Section "6. login_monitor.log (last $MonitorLogTailLines lines)"))
$monLog = Join-Path $InstallRoot 'Logs\login_monitor.log'
if (Test-Path -LiteralPath $monLog) {
Get-Content -LiteralPath $monLog -Tail $MonitorLogTailLines -ErrorAction SilentlyContinue | ForEach-Object {
[void]$report.AppendLine($_)
}
} else {
[void]$report.AppendLine('login_monitor.log не найден')
}
[void]$report.AppendLine('')
[void]$report.AppendLine('--- login_monitor.log: Notify / Skip / dedup / rdp.login (last 50 matches) ---')
if (Test-Path -LiteralPath $monLog) {
Select-String -LiteralPath $monLog -Pattern 'Notify:|Skip 4624|Notify dedup|SAC: accepted.*rdp\.login|type=rdp\.login' -ErrorAction SilentlyContinue |
Select-Object -Last 50 |
ForEach-Object { [void]$report.AppendLine($_.Line) }
}
[void]$report.Append((Write-Section '7. RDP / interactive sessions'))
[void]$report.AppendLine((Get-ExternalCommandOutput -Label 'quser' -Block { quser.exe 2>&1 }))
[void]$report.AppendLine((Get-ExternalCommandOutput -Label 'query session' -Block { query.exe session 2>&1 }))
[void]$report.Append((Write-Section "8. Security log (last $MinutesBack min)"))
if (-not $isAdmin) {
[void]$report.AppendLine('WARN: скрипт не от администратора — чтение Security может быть неполным.')
}
$since = (Get-Date).AddMinutes(-1 * [math]::Abs($MinutesBack))
$RcmLogName = 'Microsoft-Windows-TerminalServices-RemoteConnectionManager/Operational'
$isWs = Test-IsWorkstationOs
[void]$report.AppendLine("OS ProductType workstation=$isWs (server mode LT 2/3/10, workstation LT 10)")
[void]$report.AppendLine("Window StartTime >= $($since.ToString('yyyy-MM-dd HH:mm:ss'))")
[void]$report.AppendLine('')
foreach ($eid in @(4624, 4625, 4778, 4634)) {
[void]$report.AppendLine("--- Event ID $eid ---")
try {
$evs = @(Get-WinEvent -FilterHashtable @{
LogName = 'Security'
ID = $eid
StartTime = $since
} -ErrorAction Stop | Sort-Object TimeCreated)
} catch {
[void]$report.AppendLine(" (нет событий или ошибка: $($_.Exception.Message))")
continue
}
if ($evs.Count -eq 0) {
[void]$report.AppendLine(' (нет событий в окне)')
continue
}
foreach ($ev in $evs) {
if ($eid -eq 4624 -or $eid -eq 4625) {
$f = Get-LoginFieldsFromEvent -Event $ev
$verdict = if ($eid -eq 4624) { Get-MonitorVerdictFor4624 -Fields $f -IsWorkstation $isWs } else { $null }
$marker = ''
if ($ExpectedUser -and $f.Username -like "*$ExpectedUser*") { $marker = ' <<<< EXPECTED USER' }
[void]$report.AppendLine(
(" {0:yyyy-MM-dd HH:mm:ss} ID={1} User={2} LT={3} IP={4} Wks={5} Proc={6}{7}" -f
$f.TimeCreated, $f.EventId, $f.Username, $f.LogonType, $f.SourceIP, $f.ComputerName, $f.ProcessName, $marker)
)
if ($verdict) {
[void]$report.AppendLine(" MONITOR: $($verdict.Verdict)$($verdict.Reasons -join '; ')")
}
} else {
[void]$report.AppendLine(" $($ev.TimeCreated.ToString('yyyy-MM-dd HH:mm:ss')) RecordId=$($ev.RecordId)")
$f = Get-LoginFieldsFromEvent -Event $ev
if ($f.Username -ne '-') {
[void]$report.AppendLine(" User=$($f.Username) LT=$($f.LogonType) IP=$($f.SourceIP)")
}
}
}
}
[void]$report.Append((Write-Section '9. Краткий итог'))
$recent4624 = @()
try {
$recent4624 = @(Get-WinEvent -FilterHashtable @{
LogName = 'Security'
ID = 4624
StartTime = $since
} -ErrorAction SilentlyContinue)
} catch { }
$notifyable = @()
foreach ($ev in $recent4624) {
$f = Get-LoginFieldsFromEvent -Event $ev
$v = Get-MonitorVerdictFor4624 -Fields $f -IsWorkstation $isWs
if ($v.Verdict -eq 'WOULD_NOTIFY') { $notifyable += $f }
}
[void]$report.AppendLine("Security 4624 в окне: $($recent4624.Count)")
[void]$report.AppendLine("Из них базовые фильтры монитора пропустили бы: $($notifyable.Count)")
try {
$reconnectCount = @(Get-WinEvent -FilterHashtable @{
LogName = 'Security'; ID = 4778; StartTime = $since
} -ErrorAction SilentlyContinue).Count
[void]$report.AppendLine("Security 4778 (reconnect): $reconnectCount")
} catch {
[void]$report.AppendLine('Security 4778 (reconnect): n/a')
}
$sacLoginLines = @()
if (Test-Path -LiteralPath $monLog) {
$sinceLog = $since.ToString('yyyy-MM-dd HH:mm')
$sacLoginLines = @(Select-String -LiteralPath $monLog -Pattern 'SAC: accepted.*type=rdp\.login\.success' -ErrorAction SilentlyContinue |
Where-Object { $_.Line -ge $sinceLog } |
Select-Object -ExpandProperty Line)
}
[void]$report.AppendLine("SAC ingest rdp.login.success в login_monitor.log (после $sinceLog): $($sacLoginLines.Count)")
foreach ($ln in $sacLoginLines) { [void]$report.AppendLine(" $ln") }
if ($notifyable.Count -eq 0 -and $recent4624.Count -gt 0) {
[void]$report.AppendLine('')
[void]$report.AppendLine('Все 4624 в окне отфильтрованы базовыми правилами — см. MONITOR: WOULD_SKIP выше.')
[void]$report.AppendLine('Дополнительно: ignore.lst, IgnoreAdvapiNetworkLogonSourceIps в settings, dedup 90s.')
} elseif ($recent4624.Count -eq 0) {
[void]$report.AppendLine('')
[void]$report.AppendLine('Нет 4624 в окне — возможен reconnect (4778) без нового 4624.')
[void]$report.AppendLine('Повторите: Sign out → новый RDP → скрипт с -MinutesBack 5.')
}
[void]$report.AppendLine('')
[void]$report.AppendLine('UseSAC=exclusive: Telegram по rdp.login.success только из SAC (не локально агентом).')
[void]$report.AppendLine('На сервере RDS без аудита Security 4624 — смотрите RCM Operational 1149 (2.1.5-SAC+: исправлен silent skip при ComputerName=-).')
[void]$report.AppendLine('rdp.login.success = severity info; при SAC min_severity=warning Telegram не уйдёт, но событие в UI SAC должно быть.')
[void]$report.Append((Write-Section '10. RCM Operational 1149'))
$recent1149 = @()
try {
$recent1149 = @(Get-WinEvent -FilterHashtable @{
LogName = $RcmLogName
ID = 1149
StartTime = $since
} -ErrorAction SilentlyContinue)
} catch { }
[void]$report.AppendLine("RCM 1149 в окне ($RcmLogName): $($recent1149.Count)")
foreach ($ev in $recent1149 | Select-Object -First 8) {
$u = '-'; $ip = '-'
try {
if ($ev.Properties.Count -gt 0) { $u = [string]$ev.Properties[0].Value }
if ($ev.Properties.Count -gt 2) { $ip = [string]$ev.Properties[2].Value }
} catch { }
[void]$report.AppendLine(" $($ev.TimeCreated.ToString('yyyy-MM-dd HH:mm:ss')) User=$u IP=$ip")
}
$rcmNotifyLines = @()
if (Test-Path -LiteralPath $monLog) {
$sinceLog = $since.ToString('yyyy-MM-dd HH:mm')
$rcmNotifyLines = @(Select-String -LiteralPath $monLog -Pattern 'Notify RCM 1149|Skip 1149' -ErrorAction SilentlyContinue |
Where-Object { $_.Line -ge $sinceLog } |
Select-Object -ExpandProperty Line)
}
[void]$report.AppendLine("Строки Notify/Skip 1149 в login_monitor.log: $($rcmNotifyLines.Count)")
foreach ($ln in $rcmNotifyLines | Select-Object -Last 10) { [void]$report.AppendLine(" $ln") }
if ($recent1149.Count -gt 0 -and $rcmNotifyLines.Count -eq 0) {
[void]$report.AppendLine('ВНИМАНИЕ: 1149 в журнале есть, в логе агента нет Notify/Skip — вероятен баг 2.1.4 (все 1149 отбрасывались) или агент не работал в момент входа.')
}
[void]$report.AppendLine('')
[void]$report.AppendLine('Проверьте SAC: type=rdp.login.success, hostname, время входа, event_id из login_monitor.log.')
$text = $report.ToString()
[System.IO.File]::WriteAllText($OutputPath, $text, (New-Object System.Text.UTF8Encoding $true))
Write-Host ''
Write-Host "Отчёт сохранён: $OutputPath" -ForegroundColor Green
Write-Host 'Пришлите этот файл для анализа.' -ForegroundColor Cyan
Write-Host ''
Get-Content -LiteralPath $OutputPath -Tail 25
+48 -5
View File
@@ -2,13 +2,38 @@
Скрипт **`update-rdp-monitor.ps1`** на сервере публикации (например DC3) выполняет `git pull` и копирует файлы в шару.
## Путь на шаре (`-NetlogonDest`)
После `git pull` скрипт копирует дистрибутив в UNC-каталог NETLOGON:
```text
\\<имя-DC>\NETLOGON\RDP-login-monitor
```
`<имя-DC>` — NetBIOS-имя или FQDN **контроллера домена**, где лежит SYSVOL (тот же хост, с которого GPO запускает `Deploy-LoginMonitor.ps1`). Примеры:
- `\\K6A-DC3\NETLOGON\RDP-login-monitor`
- `\\dc01.corp.example.com\NETLOGON\RDP-login-monitor`
Значение по умолчанию в скрипте — **заглушка** `\\dc.contoso.local\NETLOGON\RDP-login-monitor`. В реальном домене её **нужно переопределить**, иначе после успешного `git pull` будет ошибка **«Не найден сетевой путь»** (скрипт не достучится до несуществующего хоста).
Проверка перед публикацией:
```powershell
Test-Path '\\K6A-DC3\NETLOGON\RDP-login-monitor'
# или хотя бы корень шары:
Test-Path '\\K6A-DC3\NETLOGON'
```
Должно вернуть `True` под учётной записью, с которой запускаете публикацию (на DC — обычно локальный админ; с рабочей станции — доменный админ с доступом к NETLOGON).
## Параметры по умолчанию
| Параметр | Значение |
|----------|----------|
| `$RepoPath` | `C:\Soft\Git\RDP-login-monitor` |
| `$NetlogonDest` | `\\b26\NETLOGON\RDP-login-monitor` |
| `$GitUrl` | `https://git.kalinamall.ru/PapaTramp/RDP-login-monitor.git` |
| `$NetlogonDest` | `\\dc.contoso.local\NETLOGON\RDP-login-monitor` *(заглушка — замените на свой DC)* |
| `$GitUrl` | `https://github.com/PTah/RDP-login-monitor.git` |
| `$GitBranch` | `main` |
| `$LogFile` | `C:\soft\Logs\update-rdp-monitor.log` |
@@ -28,11 +53,29 @@
## Запуск
**На сервере публикации** (клон репозитория + доступ к NETLOGON):
```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
powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\Soft\Git\RDP-login-monitor\update-rdp-monitor.ps1 `
-NetlogonDest '\\K6A-DC3\NETLOGON\RDP-login-monitor'
```
После pull обязательно проверьте **`version.txt`** на шаре — его номер определяет, подтянут ли обновления на клиентах и Exchange.
Другой remote git (закрытое зеркало):
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\Soft\Git\RDP-login-monitor\update-rdp-monitor.ps1 `
-NetlogonDest '\\K6A-DC3\NETLOGON\RDP-login-monitor' `
-GitUrl 'https://git.kalinamall.ru/PapaTramp/RDP-login-monitor.git'
```
Пробный прогон без копирования:
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\Soft\Git\RDP-login-monitor\update-rdp-monitor.ps1 `
-NetlogonDest '\\K6A-DC3\NETLOGON\RDP-login-monitor' `
-WhatIf
```
После pull обязательно проверьте **`version.txt`** на шаре — его номер определяет, подтянут ли обновления на клиентах. Метка может включать суффикс (например **`1.2.27-SAC`**); **`Deploy-LoginMonitor.ps1`** сравнивает её с **`deployed_version.txt`** по полной строке.
Файлы **`*.ps1`** при копировании пересохраняются как **UTF-8 с BOM** (иначе PowerShell 5.1 с NETLOGON может не разобрать кириллицу в скриптах). Скрипты **`Deploy-DomainMonitors.ps1`** и **`Install-DomainMonitors.ps1`** используют **ASCII** в рабочих строках — их можно запускать и без перепубликации.
+142 -141
View File
@@ -1,236 +1,237 @@
# Развёртывание RDP Login Monitor в домене
Монитор ставится в **`C:\ProgramData\RDP-login-monitor\`**, задачи планировщика создаёт сам **`Login_Monitor.ps1`** (параметр `-InstallTasks`). Доставку по сети выполняет **`Deploy-LoginMonitor.ps1`**.
Монитор ставится в **`C:\ProgramData\RDP-login-monitor\`**, задачи планировщика создаёт **`Login_Monitor.ps1 -InstallTasks`**. Доставку по сети выполняет **`Deploy-LoginMonitor.ps1`**.
См. также: [exchange-mail-security.md](exchange-mail-security.md) (отдельно, только сервер Exchange).
- Публикация на NETLOGON: [deploy-netlogon-publish.md](deploy-netlogon-publish.md)
- Exchange Mail Security (отдельный пакет): [exchange-mail-security.md](exchange-mail-security.md)
## Файлы на файловой шаре
Создайте каталог, доступный **конечным компьютерам** на чтение (часто учётная запись компьютера домена), например:
Каталог, доступный **конечным компьютерам** на чтение (учётная запись компьютера домена / SYSTEM), например:
`\\dc.contoso.local\NETLOGON\RDP-login-monitor\`
Минимум для 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`, при необходимости запускает процесс монитора. |
| `Login_Monitor.ps1` | Основной скрипт мониторинга |
| `Sac-Client.ps1` | Клиент SAC (обязателен для SAC; сверка SHA256 при деплое) |
| `login_monitor.settings.example.ps1` | Образец настроек (bootstrap локального settings) |
| `version.txt` | **Одна строка** — версия пакета на шаре (например `1.2.27-SAC`) |
| `Deploy-LoginMonitor.ps1` | Установщик для GPO / scheduled task |
Полный список файлов для публикации на шару — в [deploy-netlogon-publish.md](deploy-netlogon-publish.md).
Полный список файлов на шару (включая Exchange): [deploy-netlogon-publish.md](deploy-netlogon-publish.md).
## Как это работает
**Не копируются Deploy-LoginMonitor:** `ignore.lst`, `login_monitor.settings.ps1` (секреты локальны).
1. **`Deploy-LoginMonitor.ps1`** определяет корень дистрибутива:
- параметр **`-SourceShareRoot`** `\\server\share\RDP-login-monitor`, **или**
- если скрипт запущен по UNC, берётся **родительская папка** этого файла.
## Алгоритм Deploy (GPO startup / scheduled task)
2. Читается **`version.txt`** на шаре и сравнивается с локальной меткой **`deployed_version.txt`**. Если метки нет — подтягивается **`$ScriptVersion`** из установленного **`Login_Monitor.ps1`**.
```mermaid
flowchart TD
A[GPO startup или RDP-Login-Monitor-Deploy] --> B[Deploy-LoginMonitor.ps1 с UNC]
B --> C{Login_Monitor.ps1 и version.txt на шаре?}
C -->|нет| Z[exit 0, запись в deploy.log]
C -->|да| D[Сравнение version.txt vs deployed_version.txt]
D --> E{Нужна работа?}
E -->|версия совпадает, bundle OK, settings OK| F["Актуально — exit 0"]
E -->|новая версия / патчи / первый запуск| G[Graceful stop монитора]
G --> H[Copy Login_Monitor.ps1 + Sac-Client.ps1]
H --> I[Sync settings из example + post-patches]
I --> J[Login_Monitor.ps1 -InstallTasks]
J --> K[deployed_version.txt + deploy_last_update.txt]
K --> L[Start monitor]
```
3. Версия на шаре **совпадает** с локальной — выход без копирования.
Корень дистрибутива: **`-SourceShareRoot`** или родитель каталога `Deploy-LoginMonitor.ps1` при запуске по UNC.
4. Версия на шаре **новее** — остановка процессов монитора → копирование **`Login_Monitor.ps1`** и **`Sac-Client.ps1`** → **`Login_Monitor.ps1 -InstallTasks`** → **`deployed_version.txt`** → запуск монитора (если не **`-SkipStartMonitorAfterUpdate`**).
Локальная метка версии: **`C:\ProgramData\RDP-login-monitor\deployed_version.txt`**. Если файла нет — версия берётся из **`$ScriptVersion`** в установленном `Login_Monitor.ps1`.
5. Версия на шаре **старее** — откат блокируется, пока не указан **`-AllowDowngrade`**.
### Когда деплой **не** копирует файлы
Лог: **`C:\ProgramData\RDP-login-monitor\Logs\deploy.log`**.
Версия на шаре **совпадает** с локальной **и** одновременно:
- SHA256 **`Login_Monitor.ps1`** и **`Sac-Client.ps1`** совпадает с шарой;
- в **`login_monitor.settings.ps1`** уже есть настроенный SAC (или не требуется bootstrap);
- применены post-patches: подсказки **`$ServerDisplayName`**, **`$DailyReportEnabled`**, на Exchange — noise settings.
Иначе деплой **продолжается** (даже при совпадении номера версии): другой hash bundle, донастройка SAC, исправление `DailyReportEnabled = false` без `$`, дописывание Exchange-фильтров и т.д.
### Upgrade / downgrade
| Ситуация | Поведение |
|----------|-----------|
| Версия на шаре **новее** | Полный цикл деплоя |
| Версия **совпадает**, но нужны патчи | Полный цикл (см. выше) |
| Версия на шаре **старее** | Пропуск, пока не указан **`-AllowDowngrade`** |
Метки с суффиксом (**`1.2.27-SAC`**) сравниваются **по полной строке**; для порядка версий используется числовой префикс `1.2.27`.
Лог: **`C:\ProgramData\RDP-login-monitor\Logs\deploy.log`**. Ошибки пишутся в лог и завершаются **`exit 0`** (удобно для GPO).
## Сервер Exchange и GPO Deploy-LoginMonitor
**`Deploy-LoginMonitor.ps1` не копирует отдельный Exchange-пакет** — на MailServer те же **`Login_Monitor.ps1`** + **`Sac-Client.ps1`**, что на RDS и рабочих станциях.
При обнаружении роли Exchange (реестр, каталог установки, службы) Deploy **только дописывает** в **`login_monitor.settings.ps1`**, если строк ещё нет:
- `${Ignore4624-LT3-EmptyIP-Event} = $true`
- `$WinRmIgnoreLocalSource = 1`
- `$WinRmIgnoreMachineAccounts = 1`
- `$WinRmExchangeStrictMode = 1`
Подавление шума WinRM (HealthMailbox, loopback), ложных WinRM 91↔4624 (Outlook/LT3) и лишних **4624 LT3**.
**Мониторинг очередей транспорта и правил пересылки** — другой скрипт, **не** через GPO RDP-монитора:
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "\\dc.contoso.local\NETLOGON\RDP-login-monitor\Deploy-DomainMonitors.ps1" -Target Exchange
```
Подробно: [exchange-mail-security.md](exchange-mail-security.md).
## Локальные настройки: `login_monitor.settings.ps1`
На каждом компьютере: **`C:\ProgramData\RDP-login-monitor\login_monitor.settings.ps1`**.
Путь: **`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 (дальше правки только локально).
| Действие Deploy | Условие |
|-----------------|--------|
| Копирует example → settings | Файла settings ещё нет |
| Дописывает блок SAC из example | Нет `$UseSAC` / `$SacUrl` / `$SacApiKey` |
| **Не перезаписывает** целиком | SAC уже настроен (`UseSAC` не `off`, ключ и URL заданы) |
| Подсказка `# $ServerDisplayName = '<COMPUTERNAME>'` | Строки `$ServerDisplayName` нет |
| Подсказка / починка `$DailyReportEnabled` | Нет переменной или `= false` без `$``$false` |
| Exchange noise (см. выше) | Роль Exchange и нет соответствующих строк |
Секреты (Telegram, **`sac_*`**, SMTP, 4740) правятся **вручную** на машине.
```powershell
$root = 'C:\ProgramData\RDP-login-monitor'
Copy-Item '\\B26\NETLOGON\RDP-login-monitor\login_monitor.settings.example.ps1' `
Copy-Item '\\dc.contoso.local\NETLOGON\RDP-login-monitor\login_monitor.settings.example.ps1' `
(Join-Path $root 'login_monitor.settings.ps1')
notepad (Join-Path $root 'login_monitor.settings.ps1')
```
DPAPI: **`Encrypt-DpapiForRdpMonitor.ps1`** — строки Base64 в settings.
DPAPI: **`Encrypt-DpapiForRdpMonitor.ps1`**.
## Опционально: `ignore.lst`
### Security Alert Center
Файл **`C:\ProgramData\RDP-login-monitor\ignore.lst`** — подавление отдельных алертов **4624/4625/4740**. Синтаксис — в **[README.md](../README.md)** (раздел 7) и **`ignore.lst.example`**. Deploy с шары **`ignore.lst` не копирует**.
```powershell
$UseSAC = 'dual' # off | exclusive | dual | fallback
$SacUrl = 'https://sac.example.com'
$SacApiKey = 'sac_...'
```
## Опционально на КД: блокировки AD (4740)
Проверка: **`Login_Monitor.ps1 -CheckSac`**. После правок settings — **`Restart-RdpLoginMonitor.ps1`** или **`-RequestRestart`**.
Включается только если монитор запущен на КД с именем **`$LockoutMonitorDomainController`**. Параметры задаются в **`login_monitor.settings.ps1`** на этом КД (`$NetBiosDomainName`, `$ExchangeIisLogPath`, …).
## Задачи, heartbeat, ignore.lst
## Heartbeat и watchdog
- **`Logs\last_heartbeat.txt`** — обновление по **`$HeartbeatInterval`** (по умолчанию 1 ч).
- Нет обновления дольше **`$HeartbeatStaleAlertMultiplier × интервал`** — оповещение.
- Задача **`RDP-Login-Monitor-Watchdog`** — каждые 5 мин проверяет процесс и поднимает при падении.
## Задачи планировщика (`-InstallTasks`)
| Имя | Назначение |
|-----|------------|
| Задача | Назначение |
|--------|------------|
| **`RDP-Login-Monitor`** | Запуск при старте ОС |
| **`RDP-Login-Monitor-Watchdog`** | Контроль процесса каждые 5 мин |
Проверка:
Логи: **`login_monitor.log`**, **`watchdog.log`**, **`Logs\last_heartbeat.txt`**.
```powershell
Get-ScheduledTask -TaskName 'RDP-Login-Monitor','RDP-Login-Monitor-Watchdog' -ErrorAction SilentlyContinue
```
**`ignore.lst`** — локально, Deploy не копирует. Синтаксис: [README.md](../README.md) (раздел 7).
Логи: **`login_monitor.log`**, **`watchdog.log`**.
**4740 на КД:** только если имя узла совпадает с **`$LockoutMonitorDomainController`** в settings на этом КД.
## GPO (автозагрузка): проверенная схема
## GPO и периодический deploy
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`)
1. Файлы на `\\dc.contoso.local\NETLOGON\RDP-login-monitor\` (`update-rdp-monitor.ps1` на DC публикации).
2. GPO на OU **компьютеров****Сценарии PowerShell** автозагрузки → `Deploy-LoginMonitor.ps1`.
3. Security Filtering: группа компьютеров; на шару — Read для **SYSTEM** / Domain Computers.
4. После смены membership — **перезагрузка** (не только `gpupdate`).
**Нюансы:** UNC в NETLOGON не копируется в SYSVOL GPO — это нормально. Deploy после `-InstallTasks` делает `schtasks /Run` для немедленного старта монитора.
Deploy после `-InstallTasks` запускает монитор; задача **`RDP-Login-Monitor`** поднимет его при следующей загрузке.
### Периодический 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" `
-DeployScriptPath "\\dc.contoso.local\NETLOGON\RDP-login-monitor\Deploy-LoginMonitor.ps1" `
-RepeatMinutes 60 `
-RunNow
```
### Ручной deploy
**Ручной deploy** (от администратора):
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "\\B26\NETLOGON\RDP-login-monitor\Deploy-LoginMonitor.ps1"
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "\\dc.contoso.local\NETLOGON\RDP-login-monitor\Deploy-LoginMonitor.ps1"
```
Параметры: **`-WhatIf`**, **`-SkipStartMonitorAfterUpdate`**, **`-AllowDowngrade`**.
## Обновление на любой 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:
### A. Публикация на шару (один раз на релиз)
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass `
-File "\\b26\NETLOGON\RDP-login-monitor\Deploy-LoginMonitor.ps1"
powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\soft\update-rdp-monitor.ps1
```
Проверка:
Проверьте на шаре **`Sac-Client.ps1`** и **`version.txt`** (например `1.2.27-SAC`).
### B. На целевых машинах
GPO или scheduled task — дождаться startup / `-RunNow`. Ручная проверка:
```powershell
Get-Content 'C:\ProgramData\RDP-login-monitor\Logs\deploy.log' -Tail 20
Select-String -Path 'C:\ProgramData\RDP-login-monitor\Login_Monitor.ps1' -Pattern 'ScriptVersion'
Test-Path 'C:\ProgramData\RDP-login-monitor\Sac-Client.ps1'
Get-ScheduledTask -TaskName 'RDP-Login-Monitor','RDP-Login-Monitor-Watchdog' -ErrorAction SilentlyContinue
Get-Content 'C:\ProgramData\RDP-login-monitor\Logs\deploy.log' -Tail 15
```
### Graceful restart (без убийства PowerShell)
Повторный запуск Deploy без смены версии должен дать **`Актуально, копирование не требуется`**.
После правки **`login_monitor.settings.ps1`** (SAC, Telegram):
### C. Graceful restart
После правки settings (без смены `Login_Monitor.ps1`):
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass `
-File "C:\ProgramData\RDP-login-monitor\Login_Monitor.ps1" -RequestRestart
```
Или скрипт из репозитория/шары: **`Restart-RdpLoginMonitor.ps1`**.
Чтобы подхватить **новый `Login_Monitor.ps1` с диска** (после Deploy), нужен **recycle** — новый скрытый процесс, старый завершается сам:
После Deploy нового **`Login_Monitor.ps1`** — recycle:
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass `
-File "C:\ProgramData\RDP-login-monitor\Login_Monitor.ps1" -RequestRestart -Recycle
```
**`Deploy-LoginMonitor.ps1`** записывает **`restart.request`** напрямую (без дочернего PowerShell) и ждёт до **35 с**; **`Stop-Process -Force`** только если таймаут.
**`Deploy-LoginMonitor.ps1`** пишет **`restart.request`**, ждёт до **35 с**, затем **`Stop-Process -Force`** только при таймауте.
Сигнал: файл **`C:\ProgramData\RDP-login-monitor\restart.request`** (создаётся автоматически, не редактировать вручную).
## Обновление через SAC (WinRM)
### D. Первичная установка (ещё нет ProgramData)
Альтернатива NETLOGON/GPO: кнопка **«Обновить через WinRM»** на карточке хоста в SAC (сервер ≥ 0.20.15).
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
```
| Этап | Где | Действие |
|------|-----|----------|
| 1 | SAC | `git fetch` RDP-login-monitor → zip (`.ps1` с UTF-8 BOM) |
| 2 | Клиент (WinRM) | `Invoke-WebRequest``/api/v1/agent/rdp-bundle/<token>` |
| 3 | Клиент | Распаковка в `C:\ProgramData\RDP-login-monitor\_sac_staging` |
| 4 | Клиент | `Deploy-LoginMonitor.ps1 -SourceShareRoot _sac_staging` |
## Security Alert Center (SAC)
**Требования:** domain admin в SAC; `SAC_PUBLIC_URL` доступен с ПК; `rdp_git_repo_url` в настройках обновлений. **`Deploy-LoginMonitor.ps1` на шаре NETLOGON для этого пути не обязателен** — скрипт приходит в zip.
В **`login_monitor.settings.ps1`** на машине (не перезаписывается Deploy):
Лог операции — в модалке SAC сразу при старте. См. [agent-control-plane.md](https://git.kalinamall.ru/PapaTramp/security-alert-center/src/branch/main/docs/agent-control-plane.md) §4.3.
```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 |
| **`deployed_version.txt` локально** | Метка после успешного деплоя |
| **`$ScriptVersion` в Login_Monitor.ps1** | Версия в логах и Telegram |
Поднимайте **`version.txt`** при каждой выкладке на NETLOGON.
Поднимайте **`version.txt`** при каждой выкладке на NETLOGON; строка в **`version.txt`** и **`$ScriptVersion`** должны совпадать (включая суффикс `-SAC`).
## Безопасность
## Безопасность и UNC
- Ограничьте ACL на шару (в example/settings могут быть секреты для домена B26).
- DPAPI: **`Encrypt-DpapiForRdpMonitor.ps1`** (значения в **`login_monitor.settings.ps1`**)
- Внутренний git (git.kalinamall.ru): доверенный; секреты допустимы в **`login_monitor.settings.example.ps1`**
- Ограничьте ACL на шару (в example могут быть доменные секреты).
- DPAPI для токенов в **`login_monitor.settings.ps1`**.
- При ошибках подписи с FQDN-шары: короткое имя DC и **`-ExecutionPolicy Bypass`**.
## UNC и ExecutionPolicy
При ошибках подписи с FQDN-шары используйте короткое имя DC: `\\DC01\NETLOGON\...` и **`powershell.exe -ExecutionPolicy Bypass -File "..."`**.
Диагностика: Group Policy Operational log, **`Logs\deploy.log`**, **`deploy_installtasks_*.log`**.
+18 -6
View File
@@ -1,7 +1,9 @@
# Exchange Mail Security — руководство
# Exchange Mail Security — руководство
Скрипт **`Exchange-MailSecurity.ps1`** предназначен **только для сервера Microsoft Exchange** с Exchange Management Shell (EMS). Не устанавливается на все компьютеры домена через GPO RDP-монитора.
На Exchange-сервере GPO **`Deploy-LoginMonitor.ps1`** по-прежнему ставит **`Login_Monitor.ps1`** (RDP/WinRM/4624) и при необходимости дописывает noise settings в **`login_monitor.settings.ps1`**. Пакет **`Exchange-MailSecurity.ps1`** доставляется отдельно — см. раздел «Деплой на Exchange» ниже и [deploy-rdp-login-monitor.md](deploy-rdp-login-monitor.md).
## Назначение
| Функция | Режим | Расписание (задача планировщика) |
@@ -66,6 +68,16 @@
- Каналы: **Telegram** и/или **Email** (модуль **`Notify-Common.ps1`**).
- Пересылка: **`$AlertOnlyOnNewForwardingFindings = $true`** — алерт при **новой** находке (`Logs\exchange_forwarding_baseline.json`).
- **Первый скан:** **`$SuppressAlertsOnFirstBaselineRun = $true`** (по умолчанию) — существующие пересылки **только в baseline**, без всплеска алертов; одна **сводка** (`$SendInboxScanSummary`).
### Dry-run перед первым Inbox-сканом
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "\\dc.contoso.local\NETLOGON\RDP-login-monitor\Exchange-MailSecurity.ps1" -Mode Inbox -WhatIf
```
`-WhatIf` подключает EMS, считает объём (`Get-Mailbox` / VIP-фильтр), **не вызывает** `Get-InboxRule`, не шлёт уведомления и не пишет baseline. Рекомендуется перед `-InstallTasks` и первым ночным `-Mode Inbox`.
При полном скане без VIP скрипт пишет **WARN** в лог; проблемные ящики — в **`$SkipInboxScanMailboxes`**.
- Далее — алерт только при **новых** или **изменённых** пересылках (в т.ч. включили ранее отключённое правило).
- **`$NotifyWhenForwardingScanClean = $false`** — не слать «всё чисто» при нуле находок.
@@ -104,7 +116,7 @@ $VipMailboxPatterns = @('*@domain.ru') # опционально
### 2. Деплой на Exchange (от администратора)
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "\\B26\NETLOGON\RDP-login-monitor\Deploy-DomainMonitors.ps1" -Target Exchange
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "\\dc.contoso.local\NETLOGON\RDP-login-monitor\Deploy-DomainMonitors.ps1" -Target Exchange
```
Скрипт копирует файлы в `C:\ProgramData\RDP-login-monitor\`, вызывает **`Install-DomainMonitors.ps1 -Target Exchange`**, который регистрирует задачи планировщика.
@@ -118,7 +130,7 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File "\\B26\NETLOGON\RDP-logi
$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'
$src = '\\dc.contoso.local\NETLOGON\RDP-login-monitor\exchange_monitor.settings.example.ps1'
}
Copy-Item -LiteralPath $src -Destination (Join-Path $ex 'exchange_monitor.settings.ps1')
notepad (Join-Path $ex 'exchange_monitor.settings.ps1')
@@ -210,7 +222,7 @@ schtasks /Query /TN "RDP-Exchange-MailSecurity-Watchdog" /V /FO LIST
| `$InboxScanBatchSize` | 50 | Пауза каждые N ящиков |
| `$InboxScanBatchDelaySeconds` | 3 | Задержка между батчами |
| `$ExcludeMailboxPatterns` | HealthMailbox*, … | Исключения |
| `$SkipInboxScanMailboxes` | `k.selezneva@kalinamall.ru` | Не вызывать `Get-InboxRule` (битый rule store) |
| `$SkipInboxScanMailboxes` | `k.selezneva@example.com` | Не вызывать `Get-InboxRule` (битый rule store) |
Переопределение — в **`exchange_monitor.settings.ps1`**.
@@ -223,7 +235,7 @@ schtasks /Query /TN "RDP-Exchange-MailSecurity-Watchdog" /V /FO LIST
```text
📧 Exchange: пересылка на внешний адрес
Тип: InboxRule | MailboxForwarding | TransportRule
Ящик: user@kalinamall.ru
Ящик: user@example.com
Правило: …
Куда: attacker@gmail.com (внешний)
Важность: Критическая | Высокая
@@ -236,7 +248,7 @@ schtasks /Query /TN "RDP-Exchange-MailSecurity-Watchdog" /V /FO LIST
3. На Exchange:
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "\\B26\NETLOGON\RDP-login-monitor\Deploy-DomainMonitors.ps1" -Target Exchange
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "\\dc.contoso.local\NETLOGON\RDP-login-monitor\Deploy-DomainMonitors.ps1" -Target Exchange
```
## Устранение неполадок
+1 -1
View File
@@ -1,4 +1,4 @@
# Запуск: от администратора на ТОМ ЖЕ компьютере, где будет Login_Monitor.ps1.
# Запуск: от администратора на ТОМ ЖЕ компьютере, где будет Login_Monitor.ps1.
# Результат (Base64) вставьте в login_monitor.settings.ps1 или exchange_monitor.settings.ps1:
# $TelegramBotTokenProtectedB64 / $TelegramChatIDProtectedB64 / $MailSmtpPasswordProtectedB64.
param(
+44 -6
View File
@@ -10,12 +10,13 @@
Опционально: exchange_monitor.settings.ps1 в том же каталоге (секреты, whitelist).
#>
[CmdletBinding()]
[CmdletBinding(SupportsShouldProcess = $true)]
param(
[ValidateSet('Queues', 'Inbox', 'Watchdog')]
[string]$Mode = 'Queues',
[switch]$InstallTasks,
[switch]$Watchdog
[switch]$Watchdog,
[switch]$WhatIf
)
Set-StrictMode -Version Latest
@@ -25,7 +26,7 @@ $ErrorActionPreference = 'Stop'
# КОНФИГУРАЦИЯ
# ============================================
$ScriptVersion = '1.6.7'
$ScriptVersion = '1.6.8'
$script:InstallRoot = [System.IO.Path]::GetFullPath("$env:ProgramData\RDP-login-monitor")
$script:CanonicalScriptName = 'Exchange-MailSecurity.ps1'
$LogFile = Join-Path $script:InstallRoot 'Logs\exchange_mail_security.log'
@@ -63,11 +64,11 @@ $ScanTransportRules = $true
# VIP: только перечисленные ящики и/или шаблоны (пилот перед полным сканом).
$VipMailboxesOnly = $false
$VipMailboxes = @() # точные PrimarySmtpAddress: user@domain.com
$VipMailboxPatterns = @() # wildcard: *@kalinamall.ru, director*, finance*
$VipMailboxPatterns = @() # wildcard: *@example.com, director*, finance*
$ExcludeMailboxPatterns = @('HealthMailbox*', 'DiscoveryMailbox*', 'SystemMailbox*')
# Skip Inbox rules scan (corrupt rule store / Get-InboxRule fails). Override in exchange_monitor.settings.ps1
$SkipInboxScanMailboxes = @(
'k.selezneva@kalinamall.ru'
'broken-mailbox@example.com'
)
$ScanDisabledInboxRulesWithExternalForward = $true # отключённые правила с внешней пересылкой
$SuppressAlertsOnFirstBaselineRun = $true # первый скан: baseline без всплеска алертов
@@ -100,6 +101,18 @@ if (Test-Path -LiteralPath $SettingsFile) {
. $SettingsFile
}
function Write-ExchangeScanSafetyWarnings {
if (-not $SuppressAlertsOnFirstBaselineRun) {
Write-ExchLog 'WARN: SuppressAlertsOnFirstBaselineRun=$false — первый Inbox-скан может разослать алерты по всем уже существующим пересылкам.'
}
if (-not $VipMailboxesOnly -and $MaxMailboxesPerRun -le 0 -and $ScanInboxRules) {
Write-ExchLog 'WARN: полный скан Inbox rules по всем ящикам (VipMailboxesOnly=$false). Рекомендуется пилот: VipMailboxesOnly=$true или -Mode Inbox -WhatIf.'
}
if (@($SkipInboxScanMailboxes | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }).Count -eq 0) {
Write-ExchLog 'TIP: добавьте проблемные ящики в $SkipInboxScanMailboxes, если Get-InboxRule падает (corrupt rule store).'
}
}
function Write-NotifyLog {
param([string]$Message)
Write-ExchLog $Message
@@ -235,6 +248,7 @@ if ($InstallTasks) {
Send-ExchangeInstallNotification
Write-ExchLog 'InstallTasks: install notification sent'
}
Write-ExchLog 'InstallTasks: перед первым ночным Inbox-сканом выполните: Exchange-MailSecurity.ps1 -Mode Inbox -WhatIf'
exit 0
}
@@ -518,6 +532,15 @@ function Invoke-ExchangeQueueScan {
Write-ExchLog "Queues: threshold MessageCount > $QueueMessageCountThreshold"
$queues = @(Get-Queue -ErrorAction Stop)
if ($WhatIf) {
$hot = @($queues | Where-Object { $_.MessageCount -gt $QueueMessageCountThreshold })
Write-ExchLog "WhatIf: queues total=$($queues.Count), above threshold=$($hot.Count) — alerts skipped"
foreach ($q in $hot) {
Write-ExchLog "WhatIf: would alert queue=$($q.Identity) messages=$($q.MessageCount)"
}
return
}
$hot = @($queues | Where-Object { $_.MessageCount -gt $QueueMessageCountThreshold })
$state = Get-QueueAlertState
$now = Get-Date
@@ -760,6 +783,20 @@ function Send-ExchangeInboxScanSummary {
function Invoke-ExchangeInboxAndForwardingScan {
$scopeLabel = Get-ExchangeInboxScanScopeLabel
Write-ExchLog "Inbox/Forwarding scan v$ScriptVersion; scope: $scopeLabel; notify: $(Get-NotifyChainHuman)"
if ($WhatIf) {
$null = Import-ExchangeManagementShell
$internalDomains = Get-InternalAcceptedDomainNames
Write-ExchLog "WhatIf: accepted domains: $(@($internalDomains) -join ', ')"
$mailboxCount = 0
if ($ScanInboxRules) {
$mailboxes = @(Get-MailboxListForScan)
$mailboxCount = $mailboxes.Count
Write-ExchLog "WhatIf: would scan $mailboxCount mailboxes ($scopeLabel); Get-InboxRule not called"
}
Write-ExchLog "WhatIf: ScanMailboxForwarding=$ScanMailboxForwarding ScanTransportRules=$ScanTransportRules — no alerts, no baseline write"
return
}
$null = Import-ExchangeManagementShell
$internalDomains = Get-InternalAcceptedDomainNames
Write-ExchLog "Accepted domains (internal): $(@($internalDomains) -join ', ')"
@@ -879,7 +916,8 @@ if (-not (Test-RunningElevated)) {
Write-ExchLog 'WARNING: not running elevated - EMS/tasks may fail.'
}
Write-ExchLog "=== Exchange-MailSecurity v$ScriptVersion Mode=$Mode ==="
Write-ExchLog "=== Exchange-MailSecurity v$ScriptVersion Mode=$Mode$(if ($WhatIf) { ' WhatIf' }) ==="
Write-ExchangeScanSafetyWarnings
try {
switch ($Mode) {
+2 -2
View File
@@ -1,8 +1,8 @@
#Requires -RunAsAdministrator
#Requires -RunAsAdministrator
[CmdletBinding()]
param(
[string]$TaskName = "RDP-Login-Monitor-Deploy",
[string]$DeployScriptPath = "\\B26\NETLOGON\RDP-login-monitor\Deploy-LoginMonitor.ps1",
[string]$DeployScriptPath = "\\dc.contoso.local\NETLOGON\RDP-login-monitor\Deploy-LoginMonitor.ps1",
[ValidateRange(5, 1440)][int]$RepeatMinutes = 60,
[switch]$RunNow
)
-86
View File
@@ -1,86 +0,0 @@
#Requires -RunAsAdministrator
<#
.SYNOPSIS
Регистрирует в Планировщике заданий основной монитор и watchdog (как в README).
.DESCRIPTION
Запускайте из повышенной PowerShell. Пути по умолчанию — D:\Soft\.
Watchdog использует Watchdog_RDP_Monitor.ps1 из репозитория (проверка процесса и heartbeat).
#>
[CmdletBinding()]
param(
[string]$InstallRoot = "D:\Soft",
[string]$MainTaskName = "RDP Login Monitor",
[string]$WatchdogTaskName = "RDP Login Monitor Watchdog",
[int]$WatchdogRepeatMinutes = 5,
[int]$MainStartupRandomDelayMinutes = 1,
[int]$WatchdogStartupRandomDelayMinutes = 2
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$LoginScriptPath = Join-Path $InstallRoot "Login_Monitor.ps1"
$WatchdogScriptPath = Join-Path $InstallRoot "Watchdog_RDP_Monitor.ps1"
$LogsDir = Join-Path $InstallRoot "Logs"
if (-not (Test-Path -LiteralPath $LoginScriptPath)) {
throw "Не найден основной скрипт: $LoginScriptPath"
}
if (-not (Test-Path -LiteralPath $WatchdogScriptPath)) {
throw "Не найден watchdog: $WatchdogScriptPath"
}
if (-not (Test-Path -LiteralPath $LogsDir)) {
New-Item -ItemType Directory -Path $LogsDir -Force | Out-Null
}
$principal = New-ScheduledTaskPrincipal `
-UserId "NT AUTHORITY\SYSTEM" `
-LogonType ServiceAccount `
-RunLevel Highest
# --- Задание 1: основной монитор ---
$mainArgs = "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$LoginScriptPath`""
$mainAction = New-ScheduledTaskAction -Execute "PowerShell.exe" -Argument $mainArgs
$mainTrigger = New-ScheduledTaskTrigger -AtStartup -RandomDelay (New-TimeSpan -Minutes $MainStartupRandomDelayMinutes)
$mainSettings = New-ScheduledTaskSettingsSet `
-AllowStartIfOnBatteries `
-DontStopIfGoingOnBatteries `
-StartWhenAvailable `
-MultipleInstances IgnoreNew
Register-ScheduledTask `
-TaskName $MainTaskName `
-Action $mainAction `
-Trigger $mainTrigger `
-Principal $principal `
-Settings $mainSettings `
-Force | Out-Null
Write-Host "Создано задание: $MainTaskName" -ForegroundColor Cyan
# --- Задание 2: watchdog (старт + периодический запуск, как в README) ---
$wdArgs = "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$WatchdogScriptPath`""
$wdAction = New-ScheduledTaskAction -Execute "PowerShell.exe" -Argument $wdArgs
$wdTriggerStartup = New-ScheduledTaskTrigger -AtStartup -RandomDelay (New-TimeSpan -Minutes $WatchdogStartupRandomDelayMinutes)
$repeatDuration = New-TimeSpan -Days 3650
$anchor = (Get-Date).AddMinutes([Math]::Max(3, $WatchdogRepeatMinutes))
$wdTriggerRepeat = New-ScheduledTaskTrigger -Once -At $anchor `
-RepetitionInterval (New-TimeSpan -Minutes $WatchdogRepeatMinutes) `
-RepetitionDuration $repeatDuration
$wdSettings = New-ScheduledTaskSettingsSet `
-AllowStartIfOnBatteries `
-DontStopIfGoingOnBatteries `
-StartWhenAvailable `
-MultipleInstances IgnoreNew
Register-ScheduledTask `
-TaskName $WatchdogTaskName `
-Action $wdAction `
-Trigger @($wdTriggerStartup, $wdTriggerRepeat) `
-Principal $principal `
-Settings $wdSettings `
-Force | Out-Null
Write-Host "Создано задание: $WatchdogTaskName (триггеры: при старте ОС и каждые $WatchdogRepeatMinutes мин.)" -ForegroundColor Cyan
Write-Host "Готово. При необходимости сразу запустите: Start-ScheduledTask -TaskName '$MainTaskName'" -ForegroundColor Green
+2720 -298
View File
File diff suppressed because it is too large Load Diff
+44 -141
View File
@@ -1,159 +1,62 @@
# RDP Login Monitor
PowerShell-набор для мониторинга входов в Windows с уведомлениями в Telegram и/или Email (SMTP).
**Версия:** `2.1.14-SAC` (`$ScriptVersion` + `version.txt`)
## Актуальная схема (рекомендуется)
PowerShell-мониторинг Windows: RDP/RDS, RD Gateway, WinRM, admin share, блокировки УЗ, heartbeat, отчёты.
- Базовый путь установки: **`C:\ProgramData\RDP-login-monitor\`**.
- Основной скрипт: **`Login_Monitor.ps1`** — журнал Security **`4624`/`4625`** (логика зависит от типа ОС: рабочая станция или сервер/КД), при всплеске **`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`** создаёт:
- `RDP-Login-Monitor` (основной монитор),
- `RDP-Login-Monitor-Watchdog` (контроль процесса каждые 5 минут).
- Доменная доставка и обновления: **`Deploy-LoginMonitor.ps1`** + **`version.txt`** с шары `NETLOGON`. После успешного деплоя в приветственном сообщении (Telegram/Email) может появиться отметка об обновлении (файл **`deploy_last_update.txt`** рядом с логами).
- Документация по развёртыванию: **[Docs/README.md](Docs/README.md)** (RDP-монитор, Exchange, NETLOGON).
- **`Encrypt-DpapiForRdpMonitor.ps1`** — опционально для подготовки DPAPI-строк токена/chat id и пароля SMTP (`$MailSmtpPasswordProtectedB64` в файле настроек).
- **Локальные настройки RDP-монитора:** **`login_monitor.settings.ps1`** в каталоге установки (образец **`login_monitor.settings.example.ps1`**). При автообновлении **`Login_Monitor.ps1`** с шары файл настроек **не перезаписывается** (как **`exchange_monitor.settings.ps1`** для Exchange).
- **Security Alert Center (SAC):** модуль **`Sac-Client.ps1`** (копируется вместе с `Login_Monitor.ps1`). Режимы **`$UseSAC`**: `off` | `exclusive` | `dual` | `fallback` — контракт в репозитории **security-alert-center** (`docs/agent-integration.md`). Версия релиза: **`$ScriptVersion`** и **`version.txt`** (сейчас **1.2.17-SAC**); **`Sac-Client.ps1`** передаёт ту же версию в SAC (`product_version`).
## Что изменилось (важное)
- **Локальные настройки RDP-монитора** вынесены в **`login_monitor.settings.ps1`** (образец **`login_monitor.settings.example.ps1`**). При автообновлении **`Login_Monitor.ps1`** с шары секреты и параметры КД **не слетают**. Deploy при отсутствии settings создаёт файл один раз из example на шаре.
- **Кодировка `.ps1`**: в репозитории добавлены `.editorconfig` и `.gitattributes`, чтобы `*.ps1` по умолчанию сохранялись как **UTF-8 with BOM** и с **CRLF** (это сильно снижает “кракозябры” и ошибки парсинга PowerShell).
- **Кодировка логов**: `login_monitor.log` / `watchdog.log` пишутся как **UTF-8 с BOM** (и при необходимости BOM добавляется к уже существующему файлу), чтобы в **FAR/старых просмотрщиках** не было ситуации “в консоли нормально, а в файле РЈРІРµ…” из‑за неверной авто-кодировки.
- **`auditpol` на русской Windows**: настройка/проверка аудита опирается на категорию **`Вход/выход`** и подкатегории **`Вход в систему` / `Выход из системы`** (ожидается строка **`Успех и сбой`**). Это устраняет ошибки вида `0x00000057` из‑за несуществующего на RU ОС имени `Logon`.
- **Стабильность**: `auditpol` вызывается по полному пути `%SystemRoot%\System32\auditpol.exe` (без зависимости от PATH), stdout+stderr объединяются через `ProcessStartInfo`.
- **Агрегация 4625 (брутфорс)**: при включённом `$FailedLogonRateLimitEnabled` — уровень 1: **5** неудачных попыток за **60** с с одного источника для **одной** учётной записи (IP+user); уровень 2: **12** попыток за **60** с с одного IP (несколько логинов). Пока порог не достигнут — поштучные 4625; при всплеске — сводные алерты, одиночные подавляются. Параметры в начале `Login_Monitor.ps1`. Автоблокировка IP не выполняется.
- **Exchange Mail Security** (`Exchange-MailSecurity.ps1`): на **сервере Exchange** — очереди, пересылка на внешние адреса, watchdog. Руководство: **[Docs/exchange-mail-security.md](Docs/exchange-mail-security.md)**.
## 1) Подготовка
1. Подготовьте папку установки:
- `C:\ProgramData\RDP-login-monitor\`
2. Скопируйте в неё как минимум:
- `Login_Monitor.ps1`
- `Sac-Client.ps1`
- `login_monitor.settings.example.ps1` → переименуйте в **`login_monitor.settings.ps1`** и задайте параметры (см. п. 3)
- (для доменного развёртывания отдельно на шаре) `Deploy-LoginMonitor.ps1`, `version.txt` и `login_monitor.settings.example.ps1`
3. Настройте **`C:\ProgramData\RDP-login-monitor\login_monitor.settings.ps1`** (не редактируйте секреты в `Login_Monitor.ps1` — они перезапишутся при деплое):
- **Telegram:** `$TelegramBotToken` / `$TelegramChatID` или `...ProtectedB64`
- **Email (SMTP):** `$MailSmtpHost`, `$MailFrom`, `$MailTo`, `$MailSmtpPort` (по умолчанию 587), при необходимости `$MailSmtpUser` / `$MailSmtpPassword` (или `$MailSmtpPasswordProtectedB64` через DPAPI), `$MailSmtpStartTls` / `$MailSmtpSsl`
- **Порядок:** `$NotifyOrder` — пусто = авто (Telegram → Email, только настроенные); иначе `telegram,email` или `email` и т.п. (допускаются `tg`, `mail`)
- **SAC (опционально):** `$UseSAC`, `$SacUrl`, `$SacApiKey` — см. блок в **`login_monitor.settings.example.ps1`**. Проверка: `Login_Monitor.ps1 -CheckSac`
- **IP хоста для SAC (опционально):** `$ServerIPv4` — явный IPv4 для `host.ipv4`; если не задан, берётся автоопределение
- **Exchange-шум 4624 (опционально):** `${Ignore4624-LT3-EmptyIP-Event} = $true` — подавляет `4624` c `LogonType=3` и `IP='-'` (часто Outlook/SMTP-клиенты на почтовом сервере)
- Пошаговое обновление по домену: **[Docs/deploy-rdp-login-monitor.md](Docs/deploy-rdp-login-monitor.md)** (раздел «Обновление на любой Windows-машине»)
4. Запускайте с правами администратора (чтение `Security` журнала и регистрация задач).
5. Логи и служебные файлы будут в:
- `C:\ProgramData\RDP-login-monitor\Logs\`
6. (Опционально) Подавление части алертов по списку — см. раздел **«7) ignore.lst»** ниже.
7. (Опционально) Мониторинг блокировок AD на КД — в **`login_monitor.settings.ps1`**: **`$LockoutMonitorDomainController`** (короткое имя узла, на котором **установлен и запущен** монитор), **`$NetBiosDomainName`**, **`$ExchangeIisLogPath`**, **`$ExchangeIisLogMinutesBeforeLockout`** (по умолчанию 30), **`$ExchangeIisLogTailLines`** (по умолчанию 5000), **`$ExchangeServerHostForIisExclude`**. В оповещении: пользователь из 4740 и IP из IIS за окно до блокировки. В **`ignore.lst`** префикс **`4740:`** или **`all:`** — см. **`ignore.lst.example`**.
8. Heartbeat: при отсутствии обновления **`Logs\last_heartbeat.txt`** дольше **`$HeartbeatStaleAlertMultiplier` × `$HeartbeatInterval`** (по умолчанию 2×1 ч) — оповещение в Telegram/Email.
## 2) Ручной запуск
Используйте этот вариант для быстрой проверки старта/логики без установки задач планировщика.
## Установка (домен)
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\RDP-login-monitor\Login_Monitor.ps1"
powershell -NoProfile -ExecutionPolicy Bypass -File "\\<DC>\NETLOGON\RDP-login-monitor\Deploy-LoginMonitor.ps1"
```
Примечание: при ручном запуске монитор работает в текущей сессии до остановки (например, `Ctrl+C`).
Каталог: `C:\ProgramData\RDP-login-monitor\` · настройки: `login_monitor.settings.ps1` (не перезаписываются при деплое).
## 3) Запуск через Планировщик заданий (Task Scheduler)
## Обновление через SAC (WinRM)
Текущая схема: вручную задачи в GUI создавать не нужно.
С карточки хоста в SAC: **«Обновить через WinRM»** (SAC ≥ 0.20.15). Сервер тянет репозиторий с git, отдаёт zip по HTTPS; на ПК **не нужны** git и NETLOGON.
Достаточно запустить:
1. Staging: `C:\ProgramData\RDP-login-monitor\_sac_staging`
2. `Deploy-LoginMonitor.ps1 -SourceShareRoot` (тот же алгоритм, что при GPO)
3. Нужны: WinRM, domain admin в SAC, доступ ПК к `SAC_PUBLIC_URL`, `rdp_git_repo_url` в **Настройки → Обновления агентов**
Подробнее: [security-alert-center — agent-control-plane](https://git.kalinamall.ru/PapaTramp/security-alert-center/src/branch/main/docs/agent-control-plane.md) §4.3.
## События в SAC
| Источник | Тип SAC |
|----------|---------|
| Security 4624/4625 | `rdp.login.*` |
| Security 4634/4647 (прямой RDP, **только рабочая станция**, LT10) | `rdp.session.logoff` → закрытие сессии в SAC |
| Security 5140 | `smb.admin_share.access` |
| WinRM Operational 91 | `winrm.session.started` |
| RD Gateway 302/303 | `rdg.connection.*` → flap 302→303 в SAC |
| Security 4740 (КД) | `auth.account.locked` |
| Агент | `agent.heartbeat`, `report.daily.rdp`, `agent.inventory` |
**SAC:** `Sac-Client.ps1`, `$UseSAC` — [agent-integration.md](https://git.kalinamall.ru/PapaTramp/security-alert-center/src/branch/main/docs/agent-integration.md). Poll команд `qwinsta`/`logoff` (≥ 2.1.0-SAC).
## Ключевые файлы
| Файл | Назначение |
|------|------------|
| `Login_Monitor.ps1` | Основной цикл |
| `Sac-Client.ps1` | Отправка в SAC |
| `Deploy-LoginMonitor.ps1` | NETLOGON, задачи, WinRM self-heal |
| `login_monitor.settings.ps1` | Секреты и параметры (локально) |
## Быстрые проверки
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\RDP-login-monitor\Login_Monitor.ps1" -InstallTasks
Get-Content "C:\ProgramData\RDP-login-monitor\Logs\login_monitor.log" -Tail 60
Login_Monitor.ps1 -CheckSac
```
Скрипт сам зарегистрирует `RDP-Login-Monitor` и `RDP-Login-Monitor-Watchdog`, а также запросит немедленный первый запуск задач.
## Документация
Для доменной установки/обновления с шары вручную ничего в планировщике на клиенте настраивать не требуется: используйте `Deploy-LoginMonitor.ps1` (подробно в [Docs/deploy-rdp-login-monitor.md](Docs/deploy-rdp-login-monitor.md)).
## 4) Что проверять после запуска
- Логи:
- `C:\ProgramData\RDP-login-monitor\Logs\login_monitor.log`
- `C:\ProgramData\RDP-login-monitor\Logs\watchdog.log`
- Heartbeat:
- `C:\ProgramData\RDP-login-monitor\Logs\last_heartbeat.txt` обновляется по интервалу **`$HeartbeatInterval`** (по умолчанию раз в час).
- Ежедневный отчёт: после первого прохождения дневного слота (по умолчанию **09:00**, задаётся **`$DailyReportHour`** / **`$DailyReportMinute`** в `Login_Monitor.ps1`) уходит сводка по **`quser`** (Telegram/Email); метка последнего отчёта — `Logs\last_daily_report.txt`.
- Stale heartbeat: если **`last_heartbeat.txt`** не обновлялся дольше **`$HeartbeatStaleAlertMultiplier` × `$HeartbeatInterval`** — оповещение в Telegram/Email (см. п. 8 подготовки).
- При старте в Telegram/Email: строка **«Каналы уведомлений»** (фактический порядок доставки), плюс режим RDS/4740 по конфигурации.
- Telegram при старте: при установленном **RD Session Host** (или аналогичных компонентах RDS, не только шлюз) — строка про входы по RDP/RDS на этом сервере; при доступном журнале **RD Gateway** — отдельная строка про подключения к **внутренним целевым ПК** через шлюз (302/303). Узел только с ролью RD Gateway не дублирует формулировку «хост сессий».
- **Дубли Telegram на один RDP-вход:** Windows часто пишет **несколько 4624** с одним временем; с версии **1.2.18-SAC** второе уведомление за **`$LoginSuccessNotifyDedupSeconds`** (90 с) подавляется (`Notify dedup 4624` в логе).
- **В логе нет `Notify`, но 4624 в Security есть:** монитор обрабатывает только события **после** своего `StartTime` (окно опроса ~10 с при старте). Ищите строки **`Skip 4624:`** (фильтр LogonType / ignore.lst). Диагностика: **`tools\Show-Rdp4624Recent.ps1`**.
## 5) Автоматический перезапуск при падении
Режим `-Watchdog` внутри `Login_Monitor.ps1` делает:
- проверяет, есть ли процесс `powershell.exe/pwsh.exe` с `Login_Monitor.ps1` в командной строке;
- если процесса нет — запускает монитор;
- если монитор уже есть — не дублирует экземпляр.
## 6) Дополнительные параметры и прочие файлы
- **`-SkipScheduledTaskMaintenance`**: при обычном запуске монитора не выполнять проверку/пересоздание задач планировщика (если регистрацию задач ведёте только через **`-InstallTasks`** или вручную).
- **`Install-DeployScheduledTask.ps1`** — helper для периодического запуска **`Deploy-LoginMonitor.ps1`** с шары (см. **[DEPLOY.md](DEPLOY.md)**).
- **`Watchdog_RDP_Monitor.ps1`** и **`Install-ScheduledTasks.ps1`** — **альтернативная** схема с отдельным watchdog-файлом и путями по умолчанию **`D:\Soft`**. Для новых установок рекомендуется встроенный режим **`-Watchdog`** в **`Login_Monitor.ps1`** и задачи **`RDP-Login-Monitor`** / **`RDP-Login-Monitor-Watchdog`**.
- **`ignore.lst.example`** в репозитории — образец файла **`ignore.lst`** для подавления отдельных уведомлений Security (см. раздел 7).
- **`login_monitor.settings.example.ps1`** — образец **`login_monitor.settings.ps1`** (Telegram, SMTP, 4740, локальные IP-исключения). Deploy при первой установке может создать `login_monitor.settings.ps1` из example автоматически.
## 7) Подавление уведомлений Security: `ignore.lst`
В каталоге установки можно положить файл **`C:\ProgramData\RDP-login-monitor\ignore.lst`** (рядом с **`Login_Monitor.ps1`**). По умолчанию правила относятся к **`4624`/`4625`**; префикс **`4740:`** (или **`lockout:`**, **`блокир:`**) — только к блокировкам учётной записи; **`all:`** — и входы, и **4740**. Для **4740** тип **`ip:`** сравнивается с IP из IIS ActiveSync. Жёсткие исключения в скрипте по-прежнему для всех типов событий, кроме **4740** (там только `ignore.lst` и встроенные проверки пользователя).
События **RD Gateway (`302`/`303`)**, **RCM `1149`**, ежедневный отчёт и heartbeat **этим файлом не настраиваются**.
### Как читается файл
- Чтение выполняется по мере обработки событий; содержимое **кэшируется в памяти**. Если **`LastWriteTimeUtc`** файла изменился (редактирование и сохранение), список **перечитывается автоматически** — перезапуск монитора не обязателен.
- Кодировка: **UTF-8** (`Get-Content -Encoding UTF8`). Строка может начинаться с BOM — он отбрасывается при разборе.
- Пустые строки пропускаются. Строки, начинающиеся с **`#`** или **`;`**, считаются комментариями.
- Строка с **`:`**: берётся **первая** двоеточие — всё слева (после обрезки пробелов) определяет тип правила, всё справа — значение. Если справа пусто, строка игнорируется.
- Строка **без** **`:`**: целиком трактуется как правило типа «любое совпадение» (см. ниже).
### Префикс области (в самом начале строки, до типа правила)
| Префикс | События |
| --- | --- |
| *(нет)* | **4624**, **4625** |
| `4740:`, `lockout:`, `блокир:` | **4740** |
| `all:`, `*:` | **4624**, **4625**, **4740** |
Пример: `4740:user:svc_sync` — не слать оповещение о блокировке этой УЗ.
### Типы правил (левая часть до первого `:` после префикса области)
| Левая часть (фрагменты совпадают как regex, без учёта регистра) | Поле события |
| --- | --- |
| `рабоч`, `workstation`, `wks` | имя рабочей станции (**WorkstationName** и аналоги в XML события) |
| `польз`, `username`, `subject`, `account`, `target user`, целое слово `user` | имя пользователя (**TargetUserName** и др.) |
| `ip`, `ip адрес`, `ipaddress`, `адрес ip` | IP источника (**IpAddress** и др.), только если в событии есть непустой IP |
Если левая часть **не** подошла ни к одному типу, но двоеточие есть — используется режим как в разборе строк Telegram: тип **«любое»**, значение — **только правая часть** (метка слева отбрасывается).
### Совпадение для типа «любое» (строка без `:` или «неизвестная» метка слева от `:`)
Проверка по очереди:
1. Если значение похоже на **IPv4** — сравнивается с IP источника в событии (точное совпадение, без учёта регистра для текста не применимо).
2. Если значение содержит **`\`** — сравнивается с **учётной записью**: полное совпадение с `DOMAIN\user` **или** совпадение с **SAM** после последнего `\` (как `DOMAIN\IVANOV` при правиле `IVANOV`).
3. Иначе сначала полное совпадение с **именем рабочей станции**, затем с **учётной записью** по тем же правилам, что в п.2.
Для явных типов **User** / **Workstation** / **Ip** используется только соответствующее поле (для пользователя — те же правила полного имени и SAM, что в п.2).
### Примеры и поставка
- Расширенные примеры строк — в **`ignore.lst.example`** в корне репозитория (скопируйте на сервер как **`ignore.lst`** и отредактируйте).
- **`Deploy-LoginMonitor.ps1`** **`ignore.lst`** и **`login_monitor.settings.ps1`** **не копирует и не перезаписывает** — правила и секреты локальны; при отсутствии settings Deploy создаёт его один раз из **`login_monitor.settings.example.ps1`** на шаре.
## Ключевые слова (для поиска репозитория)
`rdp`, `rd-gateway`, `rdp-gateway`, `rds`, `remote-desktop`, `windows-security-log`, `eventlog`, `event-id-4624`, `event-id-4625`, `event-id-4740`, `event-id-302`, `event-id-303`, `account-lockout`, `active-sync`, `exchange`, `iis`, `smtp`, `email`, `powershell`, `telegram-bot`, `watchdog`, `gpo`, `netlogon`, `domain-deployment`, `windows-server`, `monitoring`
- [Docs/README.md](Docs/README.md) — развёртывание, Exchange
- [security-alert-center](https://git.kalinamall.ru/PapaTramp/security-alert-center) — сервер, RDG flap, qwinsta через SAC UI/Seaca
## English
See repository docs; agent contract is in SAC `docs/agent-integration.md`.
+4 -2
View File
@@ -1,5 +1,7 @@
# RDP Login Monitor
**Version:** `2.1.14-SAC` (`$ScriptVersion` + `version.txt`)
PowerShell toolkit for monitoring Windows logons with Telegram and/or Email (SMTP) notifications.
## Recommended layout
@@ -18,7 +20,7 @@ PowerShell toolkit for monitoring Windows logons with Telegram and/or Email (SMT
- **`.ps1` encoding**: `.editorconfig` and `.gitattributes` encourage **`*.ps1`** as **UTF-8 with BOM** and **CRLF**, reducing mojibake and PowerShell parse issues.
- **Log encoding**: `login_monitor.log` / `watchdog.log` are written as **UTF-8 with BOM** (BOM is applied to existing files if missing) so viewers like **FAR Manager** do not mis-detect encoding.
- **`auditpol` on Russian Windows**: auditing checks use the **`Вход/выход`** category and **`Вход в систему` / `Выход из системы`** subcategories (expect **`Успех и сбой`**), avoiding errors such as `0x00000057` when English names like `Logon` are absent on a localized OS.
- **`auditpol` on Russian Windows**: auditing checks use the **`Вход/выход`** category and **`Вход в систему` / `Выход из системы`** subcategories (expect **`Успех и сбой`** or **`Успех и отказ`**, depending on OS build), avoiding errors such as `0x00000057` when English names like `Logon` are absent on a localized OS.
- **Stability**: `auditpol` is invoked via full path `%SystemRoot%\System32\auditpol.exe` (no PATH dependency); stdout and stderr are merged via `ProcessStartInfo`.
- **`4625` burst alerts**: when `$FailedLogonRateLimitEnabled` is true — tier 1: **5** failures in **60** s per **IP+user**; tier 2: **12** in **60** s per **IP** (spray). Below thresholds, individual `4625` alerts are sent; during a burst, aggregated alerts replace per-event noise. No automatic IP blocking. Tune at the top of `Login_Monitor.ps1`.
- **Exchange Mail Security** (`Exchange-MailSecurity.ps1`): Exchange server only — queues, external forwarding, watchdog. See **[Docs/exchange-mail-security.md](Docs/exchange-mail-security.md)**.
@@ -90,7 +92,7 @@ For domain deployment from a share you do not configure the scheduler on clients
- **`-SkipScheduledTaskMaintenance`**: during normal monitor startup, skip verification/recreation of scheduled tasks (if you manage tasks only via **`-InstallTasks`** or manually).
- **`Install-DeployScheduledTask.ps1`** — helper to run **`Deploy-LoginMonitor.ps1`** from a share on a schedule (see **[DEPLOY.md](DEPLOY.md)**).
- **`Watchdog_RDP_Monitor.ps1`** and **`Install-ScheduledTasks.ps1`** — **alternate** layout with a separate watchdog script and default paths under **`D:\Soft`**. For new installs, prefer the built-in **`-Watchdog`** in **`Login_Monitor.ps1`** and tasks **`RDP-Login-Monitor`** / **`RDP-Login-Monitor-Watchdog`**.
- Watchdog is built into **`Login_Monitor.ps1`** (`-Watchdog`); scheduled tasks **`RDP-Login-Monitor`** / **`RDP-Login-Monitor-Watchdog`** are registered by **`-InstallTasks`**.
- **`ignore.lst.example`** in the repo is a template for **`ignore.lst`** to suppress selected Security notifications (see section 7).
- **`login_monitor.settings.example.ps1`** — template for **`login_monitor.settings.ps1`** (Telegram, SMTP, 4740, local IP exclusions). Deploy may create `login_monitor.settings.ps1` from the example on first install.
+184
View File
@@ -0,0 +1,184 @@
<#
.SYNOPSIS
Запрос задач планировщика RDP-login-monitor через schtasks /Query /XML (fallback для Get-ScheduledTask).
#>
function Get-RdpMonitorSchtasksExe {
return Join-Path $env:SystemRoot 'System32\schtasks.exe'
}
function Get-RdpMonitorScheduledTaskXmlDocument {
param(
[Parameter(Mandatory = $true)][string]$TaskName
)
$exe = Get-RdpMonitorSchtasksExe
$prevEa = $ErrorActionPreference
try {
$ErrorActionPreference = 'SilentlyContinue'
$raw = & $exe /Query /TN $TaskName /XML 2>&1
if ($LASTEXITCODE -ne 0) { return $null }
$text = ($raw | Out-String).Trim()
if ([string]::IsNullOrWhiteSpace($text)) { return $null }
if ($text -notmatch '(?s)<Task\b') { return $null }
return [xml]$text
} catch {
return $null
} finally {
$ErrorActionPreference = $prevEa
}
}
function Test-RdpMonitorScheduledTaskExistsViaSchtasks {
param(
[Parameter(Mandatory = $true)][string]$TaskName
)
return ($null -ne (Get-RdpMonitorScheduledTaskXmlDocument -TaskName $TaskName))
}
function Convert-RdpMonitorScheduledTaskExecutionTimeLimitText {
param([string]$LimitText)
if ([string]::IsNullOrWhiteSpace($LimitText)) { return $null }
$t = $LimitText.Trim()
if ($t -eq 'PT0S') { return [TimeSpan]::Zero }
try {
return [System.Xml.XmlConvert]::ToTimeSpan($t)
} catch {
return $null
}
}
function Get-RdpMonitorScheduledTaskExecutionTimeLimitFromDocument {
param([xml]$Doc)
if ($null -eq $Doc) { return $null }
$ns = New-Object System.Xml.XmlNamespaceManager($Doc.NameTable)
$ns.AddNamespace('t', 'http://schemas.microsoft.com/windows/2004/02/mit/task')
$node = $Doc.SelectSingleNode('//t:Settings/t:ExecutionTimeLimit', $ns)
if ($null -eq $node) {
$node = $Doc.SelectSingleNode('//*[local-name()="Settings"]/*[local-name()="ExecutionTimeLimit"]')
}
if ($null -eq $node -or [string]::IsNullOrWhiteSpace($node.InnerText)) { return $null }
return Convert-RdpMonitorScheduledTaskExecutionTimeLimitText -LimitText $node.InnerText.Trim()
}
function Get-RdpMonitorScheduledTaskActionFromDocument {
param([xml]$Doc)
if ($null -eq $Doc) { return $null }
$ns = New-Object System.Xml.XmlNamespaceManager($Doc.NameTable)
$ns.AddNamespace('t', 'http://schemas.microsoft.com/windows/2004/02/mit/task')
$cmdNode = $Doc.SelectSingleNode('//t:Actions/t:Exec/t:Command', $ns)
$argNode = $Doc.SelectSingleNode('//t:Actions/t:Exec/t:Arguments', $ns)
if ($null -eq $cmdNode) {
$cmdNode = $Doc.SelectSingleNode('//*[local-name()="Actions"]/*[local-name()="Exec"]/*[local-name()="Command"]')
}
if ($null -eq $argNode) {
$argNode = $Doc.SelectSingleNode('//*[local-name()="Actions"]/*[local-name()="Exec"]/*[local-name()="Arguments"]')
}
if ($null -eq $cmdNode) { return $null }
return [pscustomobject]@{
Execute = [string]$cmdNode.InnerText
Arguments = if ($null -ne $argNode) { [string]$argNode.InnerText } else { '' }
}
}
function Convert-RdpMonitorScheduledTaskExecutionTimeLimitValue {
param($Limit)
if ($null -eq $Limit) { return $null }
if ($Limit -is [TimeSpan]) { return $Limit }
return Convert-RdpMonitorScheduledTaskExecutionTimeLimitText -LimitText ([string]$Limit)
}
function Test-RdpMonitorScheduledTaskExecutionTimeLimitUnlimitedValue {
param($Limit)
$normalized = Convert-RdpMonitorScheduledTaskExecutionTimeLimitValue -Limit $Limit
if ($null -eq $normalized) { return $false }
if ($normalized.Ticks -le 0) { return $true }
if ($normalized.TotalDays -ge 999) { return $true }
return $false
}
function Get-RdpMonitorScheduledTaskExecutionTimeLimitResolved {
param(
[Parameter(Mandatory = $true)][string]$TaskName
)
try {
$limit = (Get-ScheduledTask -TaskName $TaskName -ErrorAction Stop | Select-Object -First 1).Settings.ExecutionTimeLimit
return [pscustomobject]@{
Limit = $limit
Source = 'Get-ScheduledTask'
}
} catch { }
$doc = Get-RdpMonitorScheduledTaskXmlDocument -TaskName $TaskName
if ($null -eq $doc) {
return [pscustomobject]@{
Limit = $null
Source = 'missing'
}
}
$limit = Get-RdpMonitorScheduledTaskExecutionTimeLimitFromDocument -Doc $doc
return [pscustomobject]@{
Limit = $limit
Source = 'schtasks-xml'
}
}
function Test-RdpMonitorScheduledTaskExecutionTimeLimitUnlimited {
param(
[Parameter(Mandatory = $true)][string]$TaskName
)
$resolved = Get-RdpMonitorScheduledTaskExecutionTimeLimitResolved -TaskName $TaskName
if ($resolved.Source -eq 'missing') { return $false }
return (Test-RdpMonitorScheduledTaskExecutionTimeLimitUnlimitedValue -Limit $resolved.Limit)
}
function Test-RdpMonitorScheduledTaskNeedsUnlimitedExecutionTimeLimit {
param(
[Parameter(Mandatory = $true)][string]$TaskName
)
$resolved = Get-RdpMonitorScheduledTaskExecutionTimeLimitResolved -TaskName $TaskName
if ($resolved.Source -eq 'missing') { return $true }
return (-not (Test-RdpMonitorScheduledTaskExecutionTimeLimitUnlimitedValue -Limit $resolved.Limit))
}
function Get-RdpMonitorScheduledTaskExecutionTimeLimitLabel {
param(
[Parameter(Mandatory = $true)][string]$TaskName
)
$resolved = Get-RdpMonitorScheduledTaskExecutionTimeLimitResolved -TaskName $TaskName
if ($resolved.Source -eq 'missing') { return '(task missing)' }
$limit = Convert-RdpMonitorScheduledTaskExecutionTimeLimitValue -Limit $resolved.Limit
if ($null -eq $limit) { return '(null)' }
if ($limit.Ticks -le 0) { return 'PT0S' }
return $limit.ToString()
}
function Test-RdpMonitorScheduledTaskActionMatchesViaSchtasks {
param(
[Parameter(Mandatory = $true)][string]$TaskName,
[Parameter(Mandatory = $true)][string]$ExpectedExe,
[Parameter(Mandatory = $true)][string]$ExpectedArguments
)
$doc = Get-RdpMonitorScheduledTaskXmlDocument -TaskName $TaskName
if ($null -eq $doc) { return $false }
$action = Get-RdpMonitorScheduledTaskActionFromDocument -Doc $doc
if ($null -eq $action) { return $false }
if ($action.Execute.Trim() -ne $ExpectedExe.Trim()) { return $false }
return ($action.Arguments.Trim() -eq $ExpectedArguments.Trim())
}
+1 -1
View File
@@ -1,4 +1,4 @@
<#
<#
.SYNOPSIS
Graceful restart RDP Login Monitor без Stop-Process.
.DESCRIPTION
+543 -43
View File
@@ -1,4 +1,4 @@
<#
<#
.SYNOPSIS
Клиент Security Alert Center для RDP-login-monitor.
.DESCRIPTION
@@ -167,12 +167,61 @@ function Get-SacOccurredAtIso {
return [DateTimeOffset]::Now.ToString('yyyy-MM-ddTHH:mm:ss.fffK')
}
function Get-SacOccurredAtIsoFromDateTime {
param([Parameter(Mandatory = $true)][datetime]$When)
$dt = $When
if ($dt.Kind -eq [DateTimeKind]::Unspecified) {
$dt = [datetime]::SpecifyKind($dt, [DateTimeKind]::Local)
}
return ([DateTimeOffset]$dt).ToString('yyyy-MM-ddTHH:mm:ss.fffK')
}
function Convert-AnyToJsonSerializable {
param($Value)
if ($null -eq $Value) { return $null }
if ($Value -is [string] -or $Value -is [bool] -or $Value -is [int] -or $Value -is [long] -or $Value -is [double] -or $Value -is [decimal]) {
if ($Value -is [string] -or $Value -is [bool] -or $Value -is [char]) {
return $Value
}
# PS 5.1: нет алиасов [uint]/[ulong] — IsPrimitive покрывает все числовые типы.
if ($Value.GetType().IsPrimitive) {
return $Value
}
if ($Value -is [enum]) {
return [string]$Value
}
if ($Value -is [decimal] -or $Value -is [single] -or $Value -is [double]) {
return [double]$Value
}
if ($Value -is [datetime] -or $Value -is [datetimeoffset] -or $Value -is [guid] -or $Value -is [version]) {
return [string]$Value
}
if ($Value -is [System.Management.Automation.PSMethod] -or
$Value -is [System.Management.Automation.ScriptBlock] -or
$Value -is [System.Management.Automation.PSMemberInfo] -or
$Value -is [Delegate]) {
return $null
}
$typeName = $Value.GetType().FullName
if ($typeName -like 'Microsoft.Management.Infrastructure.CimInstance*' -or
$typeName -like 'Microsoft.Management.Infrastructure.CimProperty*') {
$out = @{}
foreach ($prop in $Value.CimInstanceProperties) {
if ($null -eq $prop -or $null -eq $prop.Name) { continue }
$out[$prop.Name] = Convert-AnyToJsonSerializable $prop.Value
}
return $out
}
if ($Value -is [pscustomobject]) {
$out = @{}
foreach ($prop in $Value.PSObject.Properties) {
if ($prop.MemberType -notin @('NoteProperty', 'Property', 'AliasProperty', 'CodeProperty', 'ScriptProperty')) {
continue
}
$out[$prop.Name] = Convert-AnyToJsonSerializable $prop.Value
}
return $out
}
if ($Value -is [hashtable] -or $Value -is [System.Collections.IDictionary]) {
$out = @{}
foreach ($key in $Value.Keys) {
@@ -181,11 +230,11 @@ function Convert-AnyToJsonSerializable {
return $out
}
if ($Value -is [System.Collections.IEnumerable]) {
$list = New-Object System.Collections.Generic.List[object]
foreach ($item in $Value) {
$list.Add((Convert-AnyToJsonSerializable $item)) | Out-Null
}
return $list
return ,@(
foreach ($item in $Value) {
Convert-AnyToJsonSerializable $item
}
)
}
return [string]$Value
}
@@ -193,16 +242,7 @@ function Convert-AnyToJsonSerializable {
function ConvertTo-SacJsonText {
param([Parameter(Mandatory = $true)]$Payload)
$serializable = Convert-AnyToJsonSerializable $Payload
try {
Add-Type -AssemblyName System.Web.Extensions -ErrorAction Stop
$ser = New-Object System.Web.Script.Serialization.JavaScriptSerializer
$ser.MaxJsonLength = 16777216
$ser.RecursionLimit = 32
return ,$ser.Serialize($serializable)
} catch {
Write-SacLog "WARN: JavaScriptSerializer unavailable, fallback ConvertTo-Json ($($_.Exception.Message))"
return ,($serializable | ConvertTo-Json -Depth 12 -Compress)
}
return ,($serializable | ConvertTo-Json -Depth 16 -Compress)
}
function Get-SacCategoryForType {
@@ -253,12 +293,18 @@ function Get-SacHostBlock {
hostname = $hostname
os_family = 'windows'
}
if (Get-Variable -Name ServerDisplayName -ErrorAction SilentlyContinue) {
$displayLabel = $hostname
if (Get-Command -Name Get-MonitorServerLabelWithIp -ErrorAction SilentlyContinue) {
$displayLabel = [string](Get-MonitorServerLabelWithIp)
} elseif (Get-Variable -Name ServerDisplayName -ErrorAction SilentlyContinue) {
$label = (Get-Variable -Name ServerDisplayName -ValueOnly)
if (-not [string]::IsNullOrWhiteSpace([string]$label)) {
$hostBlock.display_name = [string]$label.Trim()
$displayLabel = [string]$label.Trim()
}
}
if (-not [string]::IsNullOrWhiteSpace($displayLabel)) {
$hostBlock.display_name = $displayLabel
}
$ipv4 = Get-SacHostIPv4
if (-not [string]::IsNullOrWhiteSpace($ipv4)) {
$hostBlock.ipv4 = $ipv4
@@ -303,7 +349,8 @@ function New-SacEventPayload {
[Parameter(Mandatory = $true)][string]$Severity,
[Parameter(Mandatory = $true)][string]$Title,
[Parameter(Mandatory = $true)][string]$Summary,
[hashtable]$Details = $null
[hashtable]$Details = $null,
$OccurredAt = $null
)
$Title = Limit-SacString -Text $Title -MaxLen 256 -Label 'title'
@@ -312,7 +359,7 @@ function New-SacEventPayload {
$payload = [ordered]@{
schema_version = '1.0'
event_id = [guid]::NewGuid().ToString()
occurred_at = (Get-SacOccurredAtIso)
occurred_at = if ($null -ne $OccurredAt) { (Get-SacOccurredAtIsoFromDateTime -When $OccurredAt) } else { (Get-SacOccurredAtIso) }
source = [ordered]@{
product = 'rdp-login-monitor'
product_version = if ($ScriptVersion) { [string]$ScriptVersion } else { 'unknown' }
@@ -373,6 +420,7 @@ function Test-SacShouldAttemptSend {
function Invoke-SacTlsPrep {
if (-not $SacTlsSkipVerify) { return }
if (-not $script:SacTlsCallbackRegistered) {
Write-SacLog 'CRITICAL: SacTlsSkipVerify=$true — TLS certificate validation disabled for SAC (MITM risk). Use only for short-lived lab debugging.'
[System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true }
$script:SacTlsCallbackRegistered = $true
}
@@ -383,7 +431,7 @@ function Test-SacHealth {
$base = Get-SacBaseUrl
if ([string]::IsNullOrWhiteSpace($base)) { return $false }
$timeout = if ($SacTimeoutSec) { [int]$SacTimeoutSec } else { 12 }
$timeout = Get-SacTimeoutSecResolved
try {
Invoke-SacTlsPrep
$resp = Invoke-WebRequest -Uri "$base/health" -Method Get -UseBasicParsing -TimeoutSec $timeout
@@ -427,6 +475,84 @@ function Move-SacSpoolToRejected {
Move-Item -LiteralPath $src -Destination $dst -Force -ErrorAction SilentlyContinue
}
function Test-SacGuidString {
param([string]$Value)
if ([string]::IsNullOrWhiteSpace($Value)) { return $false }
return ($Value.Trim() -match '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')
}
function Get-SacEventIdFromSpoolFileName {
param([string]$Path)
if ([string]::IsNullOrWhiteSpace($Path)) { return $null }
$base = [System.IO.Path]::GetFileNameWithoutExtension($Path)
if (Test-SacGuidString -Value $base) {
return $base.ToLowerInvariant()
}
return $null
}
function Get-SacEventIdFromJsonText {
param([string]$JsonText)
if ([string]::IsNullOrWhiteSpace($JsonText)) { return $null }
if ($JsonText -match '"event_id"\s*:\s*"([0-9a-fA-F-]{36})"') {
return $Matches[1].ToLowerInvariant()
}
return $null
}
function Test-SacSpoolBytesCorrupt {
param([byte[]]$Bytes)
if ($null -eq $Bytes -or $Bytes.Length -lt 2) { return $true }
# UTF-16/UTF-32 с нулевым стартом или «пустой» spool — не JSON (см. hex 00 00 00 00).
if ($Bytes[0] -eq 0 -and $Bytes[1] -eq 0) { return $true }
return $false
}
function Read-SacSpoolFileBytes {
param([Parameter(Mandatory = $true)][string]$Path)
return [System.IO.File]::ReadAllBytes($Path)
}
function Read-SacSpoolFileText {
param([Parameter(Mandatory = $true)][string]$Path)
$bytes = Read-SacSpoolFileBytes -Path $Path
if (Test-SacSpoolBytesCorrupt -Bytes $bytes) { return $null }
if ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xFE) {
return [System.Text.Encoding]::Unicode.GetString($bytes)
}
if ($bytes.Length -ge 2 -and $bytes[0] -eq 0x7B -and $Bytes[1] -eq 0) {
return [System.Text.Encoding]::Unicode.GetString($bytes)
}
$utf8 = New-Object System.Text.UTF8Encoding $false
return $utf8.GetString($bytes)
}
function Move-SacSpoolFileToRejected {
param(
[Parameter(Mandatory = $true)][string]$SpoolFilePath,
[string]$Reason = ''
)
$eventId = Get-SacEventIdFromSpoolFileName -Path $SpoolFilePath
if ($Reason) {
$label = if ($eventId) { "$eventId.json" } else { [System.IO.Path]::GetFileName($SpoolFilePath) }
Write-SacLog "WARN: SAC spool → rejected ($Reason): $label"
}
if ($eventId) {
Move-SacSpoolToRejected -EventId $eventId
return
}
$dir = Get-SacSpoolDirResolved
$name = [System.IO.Path]::GetFileName($SpoolFilePath)
$src = Join-Path $dir $name
if (-not (Test-Path -LiteralPath $src)) { return }
$rejDir = Join-Path $dir 'rejected'
if (-not (Test-Path -LiteralPath $rejDir)) {
New-Item -ItemType Directory -Path $rejDir -Force | Out-Null
}
$dst = Join-Path $rejDir $name
Move-Item -LiteralPath $src -Destination $dst -Force -ErrorAction SilentlyContinue
}
function Get-SacPostBodyBytes {
param([string]$JsonText)
$bytes = Get-SacUtf8Bytes -Text $JsonText
@@ -460,9 +586,7 @@ function Get-SacPostBodyBytes {
}
try {
$text = [System.Text.Encoding]::UTF8.GetString($bytes)
Add-Type -AssemblyName System.Web.Extensions -ErrorAction Stop
$ser = New-Object System.Web.Script.Serialization.JavaScriptSerializer
$null = $ser.DeserializeObject($text)
$null = $text | ConvertFrom-Json -ErrorAction Stop
} catch {
Write-SacLog "WARN: SAC POST aborted: local JSON parse failed ($($_.Exception.Message))"
return $null
@@ -543,7 +667,10 @@ function Write-SacPostBodyDiagnostic {
}
function Invoke-SacPostPayload {
param([string]$JsonBody)
param(
[string]$JsonBody,
[string]$SpoolFilePath = ''
)
if (-not (Test-SacConfigured)) { return $false }
if (-not (Test-SacShouldAttemptSend)) { return $false }
@@ -552,16 +679,20 @@ function Invoke-SacPostPayload {
if ([string]::IsNullOrWhiteSpace($ingest)) { return $false }
$jsonText = Repair-SacJsonText -Text (Get-SacSingleString -Value $JsonBody -Label 'spool payload')
if ($jsonText -notmatch '"event_id"\s*:\s*"([0-9a-fA-F-]{36})"') {
Write-SacLog 'WARN: SAC JSON has no event_id (uuid); skip POST'
$eventId = Get-SacEventIdFromJsonText -JsonText $jsonText
if (-not $eventId) {
if (-not [string]::IsNullOrWhiteSpace($SpoolFilePath)) {
Move-SacSpoolFileToRejected -SpoolFilePath $SpoolFilePath -Reason 'no event_id in JSON body'
} else {
Write-SacLog 'WARN: SAC JSON has no event_id (uuid); skip POST'
}
return $false
}
$eventId = $Matches[1]
$eventType = 'unknown'
if ($jsonText -match '"type"\s*:\s*"([^"]+)"') {
$eventType = $Matches[1]
}
$timeout = if ($SacTimeoutSec) { [int]$SacTimeoutSec } else { 12 }
$timeout = Get-SacTimeoutSecResolved
$spoolOnFailure = $true
$bodyBytes = $null
@@ -616,7 +747,8 @@ function Send-SacEvent {
[Parameter(Mandatory = $true)][string]$Severity,
[Parameter(Mandatory = $true)][string]$Title,
[Parameter(Mandatory = $true)][string]$Summary,
[hashtable]$Details = $null
[hashtable]$Details = $null,
$OccurredAt = $null
)
if (-not (Test-SacConfigured)) {
@@ -624,7 +756,22 @@ function Send-SacEvent {
return $false
}
$payload = $(New-SacEventPayload -EventType $EventType -Severity $Severity -Title $Title -Summary $Summary -Details $Details)
$mergedDetails = Merge-SacNotifyDetails -Details $Details -TelegramVia ''
if ($mergedDetails.Count -eq 0) {
$mergedDetails = $null
}
$payloadArgs = @{
EventType = $EventType
Severity = $Severity
Title = $Title
Summary = $Summary
Details = $mergedDetails
}
if ($null -ne $OccurredAt) {
$payloadArgs['OccurredAt'] = $OccurredAt
}
$payload = $(New-SacEventPayload @payloadArgs)
if ($payload -is [System.Array]) {
$payload = $payload[-1]
}
@@ -665,6 +812,75 @@ function Test-SacHeartbeatOnlyEventType {
return ($EventType -eq 'agent.heartbeat')
}
function Test-SacDailyReportEventType {
param([string]$EventType)
return ($EventType -in @('report.daily.rdp', 'report.daily.ssh'))
}
function Get-SacTimeoutSecResolved {
if ($SacTimeoutSec) {
$n = 0
if ([int]::TryParse([string]$SacTimeoutSec, [ref]$n) -and $n -gt 0) {
return $n
}
}
return 45
}
function Get-SacSpoolFlushMaxFilesResolved {
if (Get-Variable -Name SacSpoolFlushMaxFiles -ErrorAction SilentlyContinue) {
$n = 0
if ([int]::TryParse([string]$SacSpoolFlushMaxFiles, [ref]$n) -and $n -gt 0) {
return $n
}
}
return 50
}
function Merge-SacNotifyDetails {
param(
[hashtable]$Details = $null,
[string]$TelegramVia = ''
)
$merged = @{}
if ($null -ne $Details) {
foreach ($k in $Details.Keys) {
$merged[$k] = $Details[$k]
}
}
if (-not $merged.ContainsKey('generated_by')) {
$merged['generated_by'] = 'agent'
}
if (-not [string]::IsNullOrWhiteSpace($TelegramVia) -and -not $merged.ContainsKey('telegram_via')) {
$merged['telegram_via'] = $TelegramVia.Trim().ToLowerInvariant()
}
return $merged
}
function Get-SacEventInvokeArgs {
param(
[Parameter(Mandatory = $true)][string]$EventType,
[Parameter(Mandatory = $true)][string]$Severity,
[Parameter(Mandatory = $true)][string]$Title,
[Parameter(Mandatory = $true)][string]$Summary,
[hashtable]$Details = $null,
$OccurredAt = $null
)
$args = @{
EventType = $EventType
Severity = $Severity
Title = $Title
Summary = $Summary
Details = $Details
}
if ($null -ne $OccurredAt) {
$args['OccurredAt'] = $OccurredAt
}
return $args
}
function Send-NotifyOrSac {
param(
[Parameter(Mandatory = $true)][string]$EventType,
@@ -673,7 +889,8 @@ function Send-NotifyOrSac {
[Parameter(Mandatory = $true)][string]$Summary,
[string]$TelegramMessage = '',
[string]$EmailSubject = 'RDP Login Monitor',
[hashtable]$Details = $null
[hashtable]$Details = $null,
$OccurredAt = $null
)
if ([string]::IsNullOrWhiteSpace($TelegramMessage)) {
@@ -687,7 +904,23 @@ function Send-NotifyOrSac {
if ($mode -eq 'off') {
return $false
}
return (Send-SacEvent -EventType $EventType -Severity $Severity -Title $Title -Summary $Summary -Details $Details)
$hbDetails = Merge-SacNotifyDetails -Details $Details -TelegramVia 'sac'
$sacEventArgs = Get-SacEventInvokeArgs -EventType $EventType -Severity $Severity -Title $Title -Summary $Summary -Details $hbDetails -OccurredAt $OccurredAt
return (Send-SacEvent @sacEventArgs)
}
# Суточный отчёт — только SAC/spool; при сбое ingest не дублировать в локальный Telegram.
if (Test-SacDailyReportEventType -EventType $EventType) {
if ($mode -eq 'off') {
return $false
}
$merged = Merge-SacNotifyDetails -Details $Details -TelegramVia 'sac'
$sacEventArgs = Get-SacEventInvokeArgs -EventType $EventType -Severity $Severity -Title $Title -Summary $Summary -Details $merged -OccurredAt $OccurredAt
if (Send-SacEvent @sacEventArgs) {
return $true
}
Write-SacLog 'WARN: daily report не принят SAC — остаётся в spool (локальный Telegram пропущен)'
return $false
}
switch ($mode) {
@@ -695,14 +928,20 @@ function Send-NotifyOrSac {
return (Send-SacLocalChannels -TelegramMessage $TelegramMessage -EmailSubject $EmailSubject)
}
'exclusive' {
return (Send-SacEvent -EventType $EventType -Severity $Severity -Title $Title -Summary $Summary -Details $Details)
$merged = Merge-SacNotifyDetails -Details $Details -TelegramVia 'sac'
$sacEventArgs = Get-SacEventInvokeArgs -EventType $EventType -Severity $Severity -Title $Title -Summary $Summary -Details $merged -OccurredAt $OccurredAt
return (Send-SacEvent @sacEventArgs)
}
'dual' {
Send-SacEvent -EventType $EventType -Severity $Severity -Title $Title -Summary $Summary -Details $Details | Out-Null
$merged = Merge-SacNotifyDetails -Details $Details -TelegramVia 'agent'
$sacEventArgs = Get-SacEventInvokeArgs -EventType $EventType -Severity $Severity -Title $Title -Summary $Summary -Details $merged -OccurredAt $OccurredAt
Send-SacEvent @sacEventArgs | Out-Null
return (Send-SacLocalChannels -TelegramMessage $TelegramMessage -EmailSubject $EmailSubject)
}
'fallback' {
if (Send-SacEvent -EventType $EventType -Severity $Severity -Title $Title -Summary $Summary -Details $Details) {
$merged = Merge-SacNotifyDetails -Details $Details -TelegramVia 'sac'
$sacEventArgs = Get-SacEventInvokeArgs -EventType $EventType -Severity $Severity -Title $Title -Summary $Summary -Details $merged -OccurredAt $OccurredAt
if (Send-SacEvent @sacEventArgs) {
return $true
}
return (Send-SacLocalChannels -TelegramMessage $TelegramMessage -EmailSubject $EmailSubject)
@@ -715,7 +954,11 @@ function Send-NotifyOrSac {
}
function Invoke-SacFlushSpool {
param([int]$MaxFiles = 20)
param([int]$MaxFiles = 0)
if ($MaxFiles -le 0) {
$MaxFiles = Get-SacSpoolFlushMaxFilesResolved
}
$mode = Get-SacNormalizedMode
if ($mode -eq 'off') { return }
@@ -724,21 +967,278 @@ function Invoke-SacFlushSpool {
$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
$files = @(Get-ChildItem -LiteralPath $dir -Filter '*.json' -File -ErrorAction SilentlyContinue)
$daily = @()
$other = @()
foreach ($f in $files) {
$eventType = ''
try {
$raw = Read-SacSpoolFileText -Path $f.FullName
if (-not [string]::IsNullOrWhiteSpace($raw)) {
$obj = $raw | ConvertFrom-Json -ErrorAction Stop
if ($null -ne $obj.type) { $eventType = [string]$obj.type }
}
} catch {
$eventType = ''
}
if (Test-SacDailyReportEventType -EventType $eventType) {
$daily += $f
} else {
$other += $f
}
}
$ordered = @(
@($daily | Sort-Object LastWriteTime)
@($other | Sort-Object LastWriteTime)
)
$count = 0
foreach ($f in $ordered) {
$count++
if ($count -gt $MaxFiles) { break }
try {
$utf8 = New-Object System.Text.UTF8Encoding $false
$json = [System.IO.File]::ReadAllText($f.FullName, $utf8)
Invoke-SacPostPayload -JsonBody $json | Out-Null
$bytes = Read-SacSpoolFileBytes -Path $f.FullName
if (Test-SacSpoolBytesCorrupt -Bytes $bytes) {
Move-SacSpoolFileToRejected -SpoolFilePath $f.FullName -Reason 'null or empty payload'
continue
}
$json = Read-SacSpoolFileText -Path $f.FullName
if ([string]::IsNullOrWhiteSpace($json)) {
Move-SacSpoolFileToRejected -SpoolFilePath $f.FullName -Reason 'unreadable payload'
continue
}
Invoke-SacPostPayload -JsonBody $json -SpoolFilePath $f.FullName | Out-Null
} catch {
Write-SacLog "WARN: SAC spool flush failed for $($f.Name): $($_.Exception.Message)"
}
}
}
function Get-SacAgentCommandsUrl {
$base = Get-SacBaseUrl
if ([string]::IsNullOrWhiteSpace($base)) { return $null }
return "$base/api/v1/agent/commands"
}
function Get-SacAgentCommandResultUrl {
param([Parameter(Mandatory = $true)][string]$CommandId)
$base = Get-SacBaseUrl
if ([string]::IsNullOrWhiteSpace($base)) { return $null }
return "$base/api/v1/agent/commands/$CommandId/result"
}
function Invoke-SacHttpGet {
param(
[Parameter(Mandatory = $true)][string]$Uri,
[int]$TimeoutSec = 45
)
Invoke-SacTlsPrep
try {
$resp = Invoke-WebRequest -Uri $Uri -Method Get -UseBasicParsing -TimeoutSec $TimeoutSec `
-Headers @{ Authorization = "Bearer $SacApiKey" }
return @{ StatusCode = [int]$resp.StatusCode; Content = [string]$resp.Content }
} catch {
$code = 0
$content = ''
if ($_.Exception.Response) {
$code = [int]$_.Exception.Response.StatusCode
try {
$reader = New-Object System.IO.StreamReader($_.Exception.Response.GetResponseStream())
$content = $reader.ReadToEnd()
$reader.Close()
} catch { }
}
return @{ StatusCode = $code; Content = $content; Error = $_.Exception.Message }
}
}
function Invoke-SacHttpPostJson {
param(
[Parameter(Mandatory = $true)][string]$Uri,
[Parameter(Mandatory = $true)][string]$JsonBody,
[int]$TimeoutSec = 45
)
Invoke-SacTlsPrep
$bytes = Get-SacUtf8Bytes -Text $JsonBody
try {
$resp = Invoke-WebRequest -Uri $Uri -Method Post -UseBasicParsing -TimeoutSec $TimeoutSec `
-Headers @{ Authorization = "Bearer $SacApiKey" } `
-ContentType 'application/json; charset=utf-8' `
-Body $bytes
return @{ StatusCode = [int]$resp.StatusCode; Content = [string]$resp.Content }
} catch {
$code = 0
$content = ''
if ($_.Exception.Response) {
$code = [int]$_.Exception.Response.StatusCode
try {
$reader = New-Object System.IO.StreamReader($_.Exception.Response.GetResponseStream())
$content = $reader.ReadToEnd()
$reader.Close()
} catch { }
}
return @{ StatusCode = $code; Content = $content; Error = $_.Exception.Message }
}
}
function Invoke-SacCaptureProcess {
param(
[Parameter(Mandatory = $true)][string]$CommandLine
)
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = 'cmd.exe'
$psi.Arguments = "/c $CommandLine"
$psi.UseShellExecute = $false
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.CreateNoWindow = $true
$p = New-Object System.Diagnostics.Process
$p.StartInfo = $psi
[void]$p.Start()
$stdout = $p.StandardOutput.ReadToEnd()
$stderr = $p.StandardError.ReadToEnd()
if (-not $p.WaitForExit(60000)) {
try { $p.Kill() } catch { }
return @{ ExitCode = -1; Stdout = $stdout; Stderr = 'timeout' }
}
return @{ ExitCode = $p.ExitCode; Stdout = $stdout; Stderr = $stderr }
}
function Invoke-SacRunWithRunAs {
param(
[Parameter(Mandatory = $true)][string]$CommandLine,
$RunAs = $null
)
if ($null -eq $RunAs -or [string]::IsNullOrWhiteSpace($RunAs.user)) {
return (Invoke-SacCaptureProcess -CommandLine $CommandLine)
}
$user = [string]$RunAs.user
$password = [string]$RunAs.password
if ([string]::IsNullOrWhiteSpace($password)) {
return (Invoke-SacCaptureProcess -CommandLine $CommandLine)
}
$taskName = "SacCmd_$([guid]::NewGuid().ToString('N').Substring(0, 12))"
$outFile = Join-Path $env:TEMP "$taskName.out.txt"
$errFile = Join-Path $env:TEMP "$taskName.err.txt"
if (Test-Path -LiteralPath $outFile) { Remove-Item -LiteralPath $outFile -Force -ErrorAction SilentlyContinue }
if (Test-Path -LiteralPath $errFile) { Remove-Item -LiteralPath $errFile -Force -ErrorAction SilentlyContinue }
$wrapped = "/c `"$CommandLine > `"$outFile`" 2> `"$errFile`"`""
try {
$action = New-ScheduledTaskAction -Execute 'cmd.exe' -Argument $wrapped
Register-ScheduledTask -TaskName $taskName -Action $action -User $user -Password $password `
-RunLevel Highest -Force | Out-Null
Start-ScheduledTask -TaskName $taskName
$deadline = (Get-Date).AddSeconds(45)
do {
Start-Sleep -Milliseconds 300
$state = (Get-ScheduledTask -TaskName $taskName).State
} while ($state -eq 'Running' -and (Get-Date) -lt $deadline)
$stdout = if (Test-Path -LiteralPath $outFile) { Get-Content -LiteralPath $outFile -Raw -ErrorAction SilentlyContinue } else { '' }
$stderr = if (Test-Path -LiteralPath $errFile) { Get-Content -LiteralPath $errFile -Raw -ErrorAction SilentlyContinue } else { '' }
return @{ ExitCode = 0; Stdout = [string]$stdout; Stderr = [string]$stderr }
} catch {
return @{ ExitCode = 1; Stdout = ''; Stderr = $_.Exception.Message }
} finally {
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $outFile, $errFile -Force -ErrorAction SilentlyContinue
}
}
function Invoke-SacAgentCommand {
param(
[Parameter(Mandatory = $true)]$Command,
$RunAs = $null
)
$type = [string]$Command.type
switch ($type) {
'qwinsta' {
return (Invoke-SacRunWithRunAs -CommandLine 'qwinsta.exe' -RunAs $RunAs)
}
'logoff' {
$sid = $null
if ($Command.params -and $Command.params.session_id) {
$sid = [int]$Command.params.session_id
}
if (-not $sid) {
return @{ ExitCode = 1; Stdout = ''; Stderr = 'missing session_id' }
}
$line = "logoff.exe $sid /v"
return (Invoke-SacRunWithRunAs -CommandLine $line -RunAs $RunAs)
}
default {
return @{ ExitCode = 1; Stdout = ''; Stderr = "unknown command type: $type" }
}
}
}
function Submit-SacAgentCommandResult {
param(
[Parameter(Mandatory = $true)][string]$CommandId,
[Parameter(Mandatory = $true)][string]$Status,
[string]$Stdout = '',
[string]$Stderr = ''
)
$url = Get-SacAgentCommandResultUrl -CommandId $CommandId
if ([string]::IsNullOrWhiteSpace($url)) { return $false }
$body = @{
status = $Status
stdout = $Stdout
stderr = $Stderr
} | ConvertTo-Json -Compress
$timeout = Get-SacTimeoutSecResolved
$resp = Invoke-SacHttpPostJson -Uri $url -JsonBody $body -TimeoutSec $timeout
return ($resp.StatusCode -in 200, 201)
}
function Invoke-SacProcessPendingCommands {
if ((Get-SacNormalizedMode) -eq 'off') { return 0 }
if (-not (Test-SacConfigured)) { return 0 }
$pollUrl = Get-SacAgentCommandsUrl
if ([string]::IsNullOrWhiteSpace($pollUrl)) { return 0 }
$agentId = Get-SacAgentInstanceId
$timeout = Get-SacTimeoutSecResolved
$uri = "${pollUrl}?agent_instance_id=$([uri]::EscapeDataString($agentId))"
$get = Invoke-SacHttpGet -Uri $uri -TimeoutSec $timeout
if ($get.StatusCode -ne 200) {
if ($get.StatusCode -gt 0) {
Write-SacLog "WARN: SAC agent commands poll HTTP $($get.StatusCode)"
}
return 0
}
$parsed = $null
try {
$parsed = $get.Content | ConvertFrom-Json
} catch {
Write-SacLog "WARN: SAC agent commands poll: invalid JSON"
return 0
}
$commands = @($parsed.commands)
if ($commands.Count -eq 0) { return 0 }
$done = 0
foreach ($cmd in $commands) {
$cmdId = [string]$cmd.id
if ([string]::IsNullOrWhiteSpace($cmdId)) { continue }
Write-SacLog "SAC agent command: $cmdId type=$($cmd.type)"
$runAs = $cmd.run_as
$result = Invoke-SacAgentCommand -Command $cmd -RunAs $runAs
$status = if ([int]$result.ExitCode -eq 0) { 'completed' } else { 'failed' }
$stderr = [string]$result.Stderr
if ([int]$result.ExitCode -ne 0 -and [string]::IsNullOrWhiteSpace($stderr)) {
$stderr = "exit code $($result.ExitCode)"
}
if (Submit-SacAgentCommandResult -CommandId $cmdId -Status $status `
-Stdout ([string]$result.Stdout) -Stderr $stderr) {
$done++
Write-SacLog "SAC agent command result submitted: $cmdId status=$status"
} else {
Write-SacLog "WARN: SAC agent command result failed: $cmdId"
}
}
return $done
}
function Test-SacConnection {
Write-Host 'SAC check (rdp-login-monitor)'
Write-Host "UseSAC=$(Get-SacNormalizedMode)"
-117
View File
@@ -1,117 +0,0 @@
<#
.SYNOPSIS
Watchdog для Login_Monitor.ps1
.DESCRIPTION
Проверяет, запущен ли основной скрипт Login_Monitor.ps1.
Если нет запускает его и пишет лог.
Дополнительно проверяет heartbeat-файл и перезапускает скрипт, если heartbeat "протух".
#>
[CmdletBinding()]
param(
[string]$MainScriptPath = "D:\Soft\Login_Monitor.ps1",
[string]$HeartbeatFile = "D:\Soft\Logs\last_heartbeat.txt",
[int]$HeartbeatStaleMinutes = 90,
[string]$WatchdogLog = "D:\Soft\Logs\watchdog.log"
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$script:Utf8BomEncoding = New-Object System.Text.UTF8Encoding $true
$script:WatchdogLogBomChecked = $false
function Ensure-FileStartsWithUtf8Bom {
param([Parameter(Mandatory = $true)][string]$Path)
if (-not (Test-Path -LiteralPath $Path)) { return }
$bytes = [System.IO.File]::ReadAllBytes($Path)
if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { return }
$bom = [byte[]](0xEF, 0xBB, 0xBF)
$combined = New-Object byte[] ($bom.Length + $bytes.Length)
[Buffer]::BlockCopy($bom, 0, $combined, 0, $bom.Length)
if ($bytes.Length -gt 0) {
[Buffer]::BlockCopy($bytes, 0, $combined, $bom.Length, $bytes.Length)
}
[System.IO.File]::WriteAllBytes($Path, $combined)
}
function Write-WatchdogLog {
param([string]$Message)
$ts = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$line = "$ts - $Message" + [Environment]::NewLine
$dir = Split-Path -Parent $WatchdogLog
if ($dir -and -not (Test-Path $dir)) {
New-Item -ItemType Directory -Path $dir -Force | Out-Null
}
if (-not $script:WatchdogLogBomChecked) {
Ensure-FileStartsWithUtf8Bom -Path $WatchdogLog
$script:WatchdogLogBomChecked = $true
}
[System.IO.File]::AppendAllText($WatchdogLog, $line, $script:Utf8BomEncoding)
}
function Get-MainScriptProcesses {
try {
$procs = Get-CimInstance Win32_Process -Filter "Name = 'powershell.exe' OR Name = 'pwsh.exe'" -ErrorAction Stop
return $procs | Where-Object { $_.CommandLine -and ($_.CommandLine -like "*$MainScriptPath*") }
} catch {
Write-WatchdogLog "Ошибка проверки процессов: $($_.Exception.Message)"
return @()
}
}
function Start-MainScript {
if (-not (Test-Path $MainScriptPath)) {
Write-WatchdogLog "Основной скрипт не найден: $MainScriptPath"
return
}
$args = "-NoProfile -ExecutionPolicy Bypass -File `"$MainScriptPath`""
Start-Process -FilePath "powershell.exe" -ArgumentList $args -WindowStyle Hidden | Out-Null
Write-WatchdogLog "Основной скрипт запущен: $MainScriptPath"
}
function Stop-MainScript {
$procs = Get-MainScriptProcesses
foreach ($p in $procs) {
try {
Stop-Process -Id $p.ProcessId -Force -ErrorAction Stop
Write-WatchdogLog "Остановлен зависший экземпляр PID=$($p.ProcessId)"
} catch {
Write-WatchdogLog "Ошибка остановки PID=$($p.ProcessId): $($_.Exception.Message)"
}
}
}
function Is-HeartbeatStale {
if (-not (Test-Path $HeartbeatFile)) {
Write-WatchdogLog "Heartbeat файл отсутствует: $HeartbeatFile"
return $true
}
try {
$raw = (Get-Content $HeartbeatFile -ErrorAction Stop | Select-Object -First 1).Trim()
if (-not $raw) { return $true }
$hb = [datetime]::ParseExact($raw, "dd.MM.yyyy HH:mm:ss", $null)
$age = (Get-Date) - $hb
return ($age.TotalMinutes -gt $HeartbeatStaleMinutes)
} catch {
Write-WatchdogLog "Ошибка чтения heartbeat: $($_.Exception.Message)"
return $true
}
}
$running = Get-MainScriptProcesses
if (-not $running -or $running.Count -eq 0) {
Write-WatchdogLog "Основной скрипт не запущен, выполняю старт."
Start-MainScript
exit 0
}
if (Is-HeartbeatStale) {
Write-WatchdogLog "Heartbeat устарел, перезапускаю основной скрипт."
Stop-MainScript
Start-Sleep -Seconds 2
Start-MainScript
} else {
Write-WatchdogLog "Проверка пройдена: процесс запущен, heartbeat свежий."
}
+1 -1
View File
@@ -1,4 +1,4 @@
<#
<#
.SYNOPSIS
Пример локальных настроек Exchange-MailSecurity.ps1
.DESCRIPTION
+15 -1
View File
@@ -6,7 +6,10 @@
# Область действия (префикс в начале строки, необязателен):
# (по умолчанию) — только Security 4624/4625
# 4740: — только блокировка учётной записи (4740); для IP — любой IP из IIS
# all: — и 4624/4625, и 4740
# shadow: — RDS Shadow Control (RCM 20506/20507/20510)
# winrm: / pssession: — WinRM inbound / Enter-PSSession (Operational 91)
# smb: / 5140: / adminshare: — доступ к админ-шару C$/ADMIN$ (Security 5140)
# all: — 4624/4625, 4740, shadow, winrm, 5140
#
# Форматы правила (после префикса области):
# user:domain\user
@@ -26,5 +29,16 @@
# 4740:user:test.user
# 4740:ip:203.0.113.50
# --- только WinRM / Enter-PSSession inbound ---
# winrm:user:DOMAIN\jump-admin
# winrm:ip:192.168.160.50
# --- только admin share 5140 (C$, ADMIN$) ---
# smb:user:DOMAIN\backup-svc
# 5140:ip:192.168.160.50
# --- только RDS Shadow Control ---
# shadow:user:DOMAIN\helpdesk
# --- все перечисленные события ---
# all:user:domain\noise_account
+37 -5
View File
@@ -1,4 +1,4 @@
<#
<#
.SYNOPSIS
Локальные настройки Login_Monitor.ps1
.DESCRIPTION
@@ -9,7 +9,6 @@
#>
# --- Telegram (или DPAPI Base64 через Encrypt-DpapiForRdpMonitor.ps1) ---
# Репозиторий git.kalinamall.ru — доверенный; значения по умолчанию для домена.
$TelegramBotToken = '8239219522:AAEyOZX3cwNfgGOMDkf-mgjTIuoaOh5gF7I'
$TelegramChatID = '2843230'
# $TelegramBotTokenProtectedB64 = ''
@@ -34,13 +33,41 @@ $NotifyOrder = 'tg'
# --- Security Alert Center (SAC) ---
# off | exclusive | dual | fallback — см. security-alert-center/docs/agent-integration.md
$UseSAC = 'dual'
$UseSAC = 'fallback'
$SacUrl = 'https://sac.kalinamall.ru'
$SacApiKey = 'sac_UkOsAT3UWiQS54KK5OJPBDCSucysQDrKFju28wmYiz8'
# $SacSpoolDir = 'C:\ProgramData\RDP-login-monitor\sac-spool'
# $SacTimeoutSec = 12
$SacSpoolDir = 'C:\ProgramData\RDP-login-monitor\sac-spool'
$SacTimeoutSec = 45
$SacSpoolFlushMaxFiles = 50
$SacSpoolMaxAgeHours = 72
# $SacTlsSkipVerify = $false
# $SacFallbackFailures = 5
# $false = не слать report.daily.rdp с агента (суточный отчёт только из SAC)
# В settings.ps1 используйте 1/0 или $true/$false — не пишите голое false без $
$DailyReportEnabled = 1
# --- Heartbeat SAC (agent.heartbeat): интервал в секундах; 14400 = 4 ч ---
$HeartbeatInterval = 14400
# Оповещение, если last_heartbeat.txt не обновлялся > множитель × интервал (2 × 4 ч = 8 ч)
$HeartbeatStaleAlertMultiplier = 2
# Poll SAC на команды qwinsta/logoff (сек); см. security-alert-center/docs/agent-control-plane.md
# $SacCommandPollIntervalSec = 60
# Окно (мин): LastBootUpTime + System 41/1074/6005/6008/6009 → «старт после перезагрузки ОС»
$StartupRebootDetectMinutes = 5
# --- Инвентаризация железа/ПО для SAC (agent.inventory, раз в 12 ч) ---
$GetInventory = $true
# --- RDS Shadow Control + WinRM inbound (Enter-PSSession), severity warning ---
# $EnableRcm1149Monitoring = 1 # RCM Operational 1149 (RDP auth; workstation + RDS server)
# $EnableRcmShadowControlMonitoring = 1 # RCM Operational 20506/20507/20510
# $EnableWinRmInboundMonitoring = 1 # WinRM Operational 91 (+ correlate Security 4624)
# $EnableAdminShareMonitoring = 1 # Security 5140 C$/ADMIN$ (audit File Share)
# $WinRmIgnoreLocalSource = 1 # ::1, 127.0.0.1, fe80 (шум Exchange/локальный WinRM)
# $WinRmIgnoreMachineAccounts = 1 # учётки, оканчивающиеся на $
# $WinRmExchangeStrictMode = 1 # Exchange: user в Event 91 обязателен; 4624 только LogonProcess WinRM
# HealthMailbox* уже в ExcludedUserPatterns скрипта
# Проверка: powershell -File Login_Monitor.ps1 -CheckSac
# --- Узкое исключение шумовых сетевых логонов (LogonType=3, Advapi) ---
@@ -51,6 +78,11 @@ $IgnoreAdvapiNetworkLogonSourceIps = @(
# Включайте на почтовом сервере, если нужен только полезный интерактивный сигнал.
${Ignore4624-LT3-EmptyIP-Event} = $false
# --- Ротация login_monitor.log и хранение бэкапов (Logs\Backup\LoginLog_*.bak) ---
# $LogRotationHour = 0
# $LogRotationMinute = 0
$MaxBackupDays = 31
# --- Блокировка учётной записи AD (4740) + IP из IIS ActiveSync ---
# Мониторинг включается только на КД с именем $LockoutMonitorDomainController.
$LockoutMonitorDomainController = 'K6A-DC3'
+39
View File
@@ -0,0 +1,39 @@
# Push sanitized main to GitHub without leaving secrets on local main (kalinamall workflow).
# Usage: .\scripts\Push-GitHubMirror.ps1
param(
[string]$Remote = 'github',
[string]$Branch = 'main'
)
$ErrorActionPreference = 'Stop'
$Root = Split-Path -Parent $PSScriptRoot
Set-Location $Root
git remote get-url $Remote 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "remote not configured: $Remote"
}
if ((git status --porcelain)) {
throw 'working tree not clean; commit or stash first'
}
$before = (git rev-parse HEAD).Trim()
Write-Output "local HEAD before GitHub push: $before"
& "$PSScriptRoot\Sanitize-ForGitHub.ps1"
& "$PSScriptRoot\Rewrite-GitHostUrls.ps1" -Target github
& "$PSScriptRoot\Test-NoSecretsForGitHub.ps1"
$status = git status --porcelain
if (-not $status) {
Write-Output 'no changes after sanitize; pushing current HEAD to GitHub'
git push $Remote $Branch
exit 0
}
git add -A
git commit -m "chore(github): sanitize secrets and sync public mirror URLs"
git push --force-with-lease $Remote $Branch
git reset --hard $before
Write-Output "pushed $Remote/$Branch (force-with-lease mirror); local main restored to $before (production/kalinamall)"
+41
View File
@@ -0,0 +1,41 @@
# Push main to a mirror remote with host-specific doc URLs, without leaving URL churn on main.
# Usage: .\scripts\Push-Mirror.ps1 github|kalinamall|papatramp
param(
[Parameter(Mandatory = $true)]
[ValidateSet('github', 'kalinamall', 'papatramp')]
[string]$Target
)
$ErrorActionPreference = 'Stop'
$Root = Split-Path -Parent $PSScriptRoot
Set-Location $Root
$remote = switch ($Target) {
'github' { 'github' }
'kalinamall' { 'kalinamall' }
'papatramp' { 'papatramp' }
}
git remote get-url $remote 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "remote not configured: $remote"
}
if ((git status --porcelain)) {
throw 'working tree not clean; commit or stash first'
}
$before = (git rev-parse HEAD).Trim()
& "$PSScriptRoot\Rewrite-GitHostUrls.ps1" -Target $Target
if (-not (git status --porcelain)) {
Write-Output "no URL changes for $Target; pushing as-is"
git push $remote main
exit 0
}
git add -A
git commit -m "chore(docs): sync repository URLs for ${Target} mirror"
git push $remote main
git reset --hard $before
Write-Output "pushed $remote with ${Target} URLs; local main reset to $before"
+67
View File
@@ -0,0 +1,67 @@
# Push main to kalinamall / papatramp with production secrets and paths.
# GitHub (origin) stays sanitized — never push this commit to origin.
# Usage: .\scripts\Push-PrivateMirror.ps1 kalinamall|papatramp
param(
[Parameter(Mandatory = $true)]
[ValidateSet('kalinamall', 'papatramp')]
[string]$Target
)
$ErrorActionPreference = 'Stop'
$Root = Split-Path -Parent $PSScriptRoot
Set-Location $Root
$remote = $Target
git remote get-url $remote 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "remote not configured: $remote (git remote add $remote <url>)"
}
if ((git status --porcelain)) {
throw 'working tree not clean; commit or stash first'
}
$before = (git rev-parse HEAD).Trim()
git fetch $remote main 2>$null
if ($LASTEXITCODE -ne 0) {
git fetch $remote 2>$null
}
# Production-only files: real tokens, NETLOGON paths, org hostnames.
$privateFiles = @(
'login_monitor.settings.example.ps1',
'update-rdp-monitor.ps1',
'exchange_monitor.settings.example.ps1'
)
$hadPrivate = $false
foreach ($f in $privateFiles) {
$ref = "${remote}/main"
git rev-parse "$ref`:$f" 2>$null | Out-Null
if ($LASTEXITCODE -eq 0) {
git checkout "$ref" -- $f
$hadPrivate = $true
Write-Output "restored from ${remote}/main: $f"
} else {
Write-Warning "skip (not on ${remote}/main): $f"
}
}
if (-not $hadPrivate) {
throw "no private files on ${remote}/main; restore production files manually once, then re-run"
}
& "$PSScriptRoot\Rewrite-GitHostUrls.ps1" -Target $Target
git add -A
$status = git status --porcelain
if (-not $status) {
Write-Output "no changes vs local main; pushing as-is to $remote"
git push $remote main
exit 0
}
git commit -m "chore(private): sync production secrets and paths for ${Target} mirror"
git push $remote main
git reset --hard $before
Write-Output "pushed $remote with production files; local main reset to $before (GitHub-safe)"
+62
View File
@@ -0,0 +1,62 @@
# Rewrite cross-repo URLs in tracked docs/config for the target Git host.
# Usage: .\scripts\Rewrite-GitHostUrls.ps1 github|kalinamall|papatramp
param(
[Parameter(Mandatory = $true)]
[ValidateSet('github', 'kalinamall', 'papatramp')]
[string]$Target
)
$ErrorActionPreference = 'Stop'
$Root = Split-Path -Parent $PSScriptRoot
Set-Location $Root
switch ($Target) {
'github' {
$Base = 'https://github.com/PTah'
$BlobSuffix = '/blob/main'
}
'kalinamall' {
$Base = 'https://git.kalinamall.ru/PapaTramp'
$BlobSuffix = '/src/branch/main'
}
'papatramp' {
$Base = 'https://git.papatramp.ru/PapaTramp'
$BlobSuffix = '/src/branch/main'
}
}
$BaseHost = $Base -replace '^https://', ''
$patterns = @(
@{ From = 'https://github.com/PTah/([^)/''"\s]+)/blob/main/'; To = "$Base/`${1}$BlobSuffix/" }
@{ From = 'https://github.com/PTah/([^)/''"\s]+)/src/branch/main/'; To = "$Base/`${1}$BlobSuffix/" }
@{ From = 'https://git.kalinamall.ru/PapaTramp/([^)/''"\s]+)/blob/main/'; To = "$Base/`${1}$BlobSuffix/" }
@{ From = 'https://git\.kalinamall\.ru/PapaTramp/([^)/''"\s]+)/src/branch/main/'; To = "$Base/`${1}$BlobSuffix/" }
@{ From = 'https://git.papatramp.ru/PapaTramp/([^)/''"\s]+)/src/branch/main/'; To = "$Base/`${1}$BlobSuffix/" }
@{ From = 'https://git.papatramp.ru/PTah/([^)/''"\s]+)/src/branch/main/'; To = "$Base/`${1}$BlobSuffix/" }
@{ From = 'https://github.com/PTah/'; To = "$Base/" }
@{ From = 'https://git.kalinamall.ru/PapaTramp/'; To = "$Base/" }
@{ From = 'https://git.papatramp.ru/PapaTramp/'; To = "$Base/" }
@{ From = 'https://git.papatramp.ru/PTah/'; To = "$Base/" }
@{ From = 'github.com/PTah/'; To = "$BaseHost/" }
@{ From = 'git.kalinamall.ru/PapaTramp/'; To = "$BaseHost/" }
@{ From = 'git.papatramp.ru/PapaTramp/'; To = "$BaseHost/" }
@{ From = 'git.papatramp.ru/PTah/'; To = "$BaseHost/" }
)
$extensions = @('*.md', '*.json', '*.service', '*.example', '*.sh', '*.ps1', '*.yml', '*.yaml')
$files = git ls-files $extensions 2>$null | Where-Object { $_ -and (Test-Path $_) }
foreach ($file in $files) {
$content = [System.IO.File]::ReadAllText((Join-Path $Root $file))
$updated = $content
foreach ($p in $patterns) {
$updated = [regex]::Replace($updated, $p.From, $p.To)
}
if ($updated -ne $content) {
[System.IO.File]::WriteAllText((Join-Path $Root $file), $updated, [System.Text.UTF8Encoding]::new($false))
Write-Output "updated: $file"
}
}
Write-Output "Rewrite-GitHostUrls: target=$Target base=$Base"
+169
View File
@@ -0,0 +1,169 @@
# Replace production-only values with public-safe placeholders (GitHub mirror).
# Usage: .\scripts\Sanitize-ForGitHub.ps1
# Reversible: production copies live on kalinamall/papatramp; restore via git reset --hard.
param(
[switch]$WhatIf
)
$ErrorActionPreference = 'Stop'
$Root = Split-Path -Parent $PSScriptRoot
Set-Location $Root
function Set-TrackedFileText {
param(
[Parameter(Mandatory = $true)][string]$RelativePath,
[Parameter(Mandatory = $true)][string]$Content
)
$path = Join-Path $Root $RelativePath
if ($WhatIf) {
Write-Output "WhatIf: would write $RelativePath"
return
}
$utf8Bom = New-Object System.Text.UTF8Encoding $true
[System.IO.File]::WriteAllText($path, $Content.TrimEnd() + "`r`n", $utf8Bom)
Write-Output "sanitized: $RelativePath"
}
$loginSettings = @'
<#
.SYNOPSIS
Локальные настройки Login_Monitor.ps1
.DESCRIPTION
Скопируйте в C:\ProgramData\RDP-login-monitor\login_monitor.settings.ps1
и при необходимости отредактируйте. Deploy-LoginMonitor.ps1 не перезаписывает settings,
если SAC уже настроен (UseSAC не off и задан SacApiKey). При первой установке или апгрейде
с версии без SAC (нет Sac-Client.ps1 / пустой ключ) example копируется поверх с резервной .bak.
#>
# --- Telegram (или DPAPI Base64 через Encrypt-DpapiForRdpMonitor.ps1) ---
$TelegramBotToken = 'YOUR_BOT_TOKEN'
$TelegramChatID = 'YOUR_CHAT_ID'
# $TelegramBotTokenProtectedB64 = ''
# $TelegramChatIDProtectedB64 = ''
# --- Email (опционально) ---
$NotifyOrder = 'tg'
# $MailSmtpHost = 'smtp.example.com'
# $MailSmtpPort = 587
# $MailSmtpUser = ''
# $MailSmtpPassword = ''
# $MailFrom = 'monitor@example.com'
# $MailTo = 'admin@example.com'
# $MailSmtpStartTls = $true
# $MailSmtpSsl = $false
# $MailSmtpPasswordProtectedB64 = ''
# --- Подпись сервера в Telegram и SAC (host.display_name); пусто = $env:COMPUTERNAME ---
# $ServerDisplayName = 'RDP-Server-01'
# --- Явный IPv4 хоста для SAC (опционально; иначе автоопределение) ---
# $ServerIPv4 = '192.168.1.10'
# --- Security Alert Center (SAC) ---
# off | exclusive | dual | fallback — см. security-alert-center/docs/agent-integration.md
$UseSAC = 'fallback'
$SacUrl = 'https://sac.example.com'
$SacApiKey = 'sac_CHANGE_ME'
$SacSpoolDir = 'C:\ProgramData\RDP-login-monitor\sac-spool'
$SacTimeoutSec = 45
$SacSpoolFlushMaxFiles = 50
$SacSpoolMaxAgeHours = 72
# $SacTlsSkipVerify = $false
# $SacFallbackFailures = 5
# $false = не слать report.daily.rdp с агента (суточный отчёт только из SAC)
# В settings.ps1 используйте 1/0 или $true/$false — не пишите голое false без $
$DailyReportEnabled = 1
# --- Heartbeat SAC (agent.heartbeat): интервал в секундах; 14400 = 4 ч ---
$HeartbeatInterval = 14400
# Оповещение, если last_heartbeat.txt не обновлялся > множитель × интервал (2 × 4 ч = 8 ч)
$HeartbeatStaleAlertMultiplier = 2
# Poll SAC на команды qwinsta/logoff (сек); см. security-alert-center/docs/agent-control-plane.md
# $SacCommandPollIntervalSec = 60
# Окно (мин): LastBootUpTime + System 41/1074/6005/6008/6009 → «старт после перезагрузки ОС»
$StartupRebootDetectMinutes = 5
# --- Инвентаризация железа/ПО для SAC (agent.inventory, раз в 12 ч) ---
$GetInventory = $true
# --- RDS Shadow Control + WinRM inbound (Enter-PSSession), severity warning ---
# $EnableRcm1149Monitoring = 1 # RCM Operational 1149 (RDP auth; workstation + RDS server)
# $EnableRcmShadowControlMonitoring = 1 # RCM Operational 20506/20507/20510
# $EnableWinRmInboundMonitoring = 1 # WinRM Operational 91 (+ correlate Security 4624)
# $EnableAdminShareMonitoring = 1 # Security 5140 C$/ADMIN$ (audit File Share)
# $WinRmIgnoreLocalSource = 1 # ::1, 127.0.0.1, fe80 (шум Exchange/локальный WinRM)
# $WinRmIgnoreMachineAccounts = 1 # учётки, оканчивающиеся на $
# $WinRmExchangeStrictMode = 1 # Exchange: user в Event 91 обязателен; 4624 только LogonProcess WinRM
# HealthMailbox* уже в ExcludedUserPatterns скрипта
# Проверка: powershell -File Login_Monitor.ps1 -CheckSac
# --- Узкое исключение шумовых сетевых логонов (LogonType=3, Advapi) ---
$IgnoreAdvapiNetworkLogonSourceIps = @(
'192.168.1.10'
)
# --- Exchange noise filter: 4624 + LogonType=3 + IP='-' (часто Outlook/почтовые клиенты) ---
# Включайте на почтовом сервере, если нужен только полезный интерактивный сигнал.
${Ignore4624-LT3-EmptyIP-Event} = $false
# --- Ротация login_monitor.log и хранение бэкапов (Logs\Backup\LoginLog_*.bak) ---
# $LogRotationHour = 0
# $LogRotationMinute = 0
$MaxBackupDays = 31
# --- Блокировка учётной записи AD (4740) + IP из IIS ActiveSync ---
# Мониторинг включается только на КД с именем $LockoutMonitorDomainController.
$LockoutMonitorDomainController = 'dc01.contoso.local'
$NetBiosDomainName = 'CONTOSO'
$ExchangeIisLogPath = '\\mail.contoso.local\c$\inetpub\logs\LogFiles\W3SVC1'
$ExchangeServerHostForIisExclude = ''
$ExchangeIisLogTailLines = 5000
$ExchangeIisLogMinutesBeforeLockout = 30
'@
Set-TrackedFileText -RelativePath 'login_monitor.settings.example.ps1' -Content $loginSettings
$exchangeSettingsPath = Join-Path $Root 'exchange_monitor.settings.example.ps1'
if (Test-Path -LiteralPath $exchangeSettingsPath) {
$ex = Get-Content -LiteralPath $exchangeSettingsPath -Raw
$ex = $ex -replace 'kalinamall\.ru', 'example.com'
$ex = $ex -replace 'fifth\.example\.com', 'mail.contoso.local'
$ex = $ex -replace 'k\.selezneva@example\.com', 'broken-mailbox@example.com'
Set-TrackedFileText -RelativePath 'exchange_monitor.settings.example.ps1' -Content $ex
}
$updatePath = Join-Path $Root 'update-rdp-monitor.ps1'
if (Test-Path -LiteralPath $updatePath) {
$upd = Get-Content -LiteralPath $updatePath -Raw
$upd = $upd -replace "Posle fetch: vsegda reset --hard na kalinamall/main \(bez merge\), zatem clean -fd\.",
'Posle fetch: reset --hard na upstream/main (bez merge), zatem clean -fd.'
$upd = $upd -replace "\\\\b26\\NETLOGON\\RDP-login-monitor", '\\dc.contoso.local\NETLOGON\RDP-login-monitor'
$upd = $upd -replace "https://git\.kalinamall\.ru/PapaTramp/RDP-login-monitor\.git",
'https://github.com/PTah/RDP-login-monitor.git'
Set-TrackedFileText -RelativePath 'update-rdp-monitor.ps1' -Content $upd
}
$netlogonDoc = Join-Path $Root 'Docs/deploy-netlogon-publish.md'
if (Test-Path -LiteralPath $netlogonDoc) {
$doc = Get-Content -LiteralPath $netlogonDoc -Raw
$doc = $doc -replace 'K6A-DC3', 'dc01.corp.example.com'
$doc = $doc -replace '\\\\b26\\', '\\dc.contoso.local\'
$doc = $doc -replace "https://git\.kalinamall\.ru/PapaTramp/RDP-login-monitor\.git",
'https://git.example.com/org/RDP-login-monitor.git'
Set-TrackedFileText -RelativePath 'Docs/deploy-netlogon-publish.md' -Content $doc
}
$mdFiles = @(git ls-files '*.md' 2>$null | Where-Object { $_ -and (Test-Path $_) })
foreach ($rel in $mdFiles) {
$path = Join-Path $Root $rel
$md = Get-Content -LiteralPath $path -Raw
$orig = $md
$md = $md -replace 'https://git\.kalinamall\.ru/PapaTramp/([^)/\s]+)/src/branch/main/', 'https://github.com/PTah/$1/blob/main/'
$md = $md -replace 'https://git\.papatramp\.ru/PapaTramp/([^)/\s]+)/src/branch/main/', 'https://github.com/PTah/$1/blob/main/'
$md = $md -replace 'https://git\.kalinamall\.ru/PapaTramp/', 'https://github.com/PTah/'
$md = $md -replace 'https://git\.papatramp\.ru/PapaTramp/', 'https://github.com/PTah/'
if ($md -ne $orig) {
Set-TrackedFileText -RelativePath $rel -Content $md
}
}
Write-Output 'Sanitize-ForGitHub: done'
+47
View File
@@ -0,0 +1,47 @@
# Fail if tracked text still contains production-only markers (run before GitHub push).
$ErrorActionPreference = 'Stop'
$Root = Split-Path -Parent $PSScriptRoot
Set-Location $Root
$patterns = @(
'8239219522',
'sac_UkOsAT',
'2843230',
'sac\.kalinamall\.ru',
'\\\\b26\\',
'K6A-DC3',
'fifth\.kalinamall',
'192\.168\.160\.57',
'kalinamall\.ru',
'git\.kalinamall\.ru',
'git\.papatramp\.ru',
'\d{8,12}:[A-Za-z0-9_-]{20,}'
)
$extensions = @('*.md', '*.ps1', '*.example', '*.txt', '*.json', '*.yml', '*.yaml')
$files = git ls-files $extensions 2>$null | Where-Object { $_ -and (Test-Path $_) }
$hits = @()
foreach ($file in $files) {
if ($file -like 'scripts/Rewrite-GitHostUrls.ps1') { continue }
if ($file -like 'scripts/Push-PrivateMirror.ps1') { continue }
if ($file -like 'scripts/Sanitize-ForGitHub.ps1') { continue }
if ($file -like 'scripts/Test-NoSecretsForGitHub.ps1') { continue }
if ($file -like 'scripts/Push-GitHubMirror.ps1') { continue }
if ($file -like 'scripts/Push-Mirror.ps1') { continue }
$text = Get-Content -LiteralPath $file -Raw -ErrorAction SilentlyContinue
if ([string]::IsNullOrEmpty($text)) { continue }
foreach ($pat in $patterns) {
if ($text -match $pat) {
$hits += "${file}: matches /$pat/"
}
}
}
if ($hits.Count -gt 0) {
Write-Error ("GitHub secret scan failed:`n" + ($hits -join "`n"))
}
Write-Output "GitHub secret scan: OK ($($files.Count) files)"
+46
View File
@@ -0,0 +1,46 @@
<#
.SYNOPSIS
Smoke/autotests for RDP-login-monitor deploy and SAC paths.
.EXAMPLE
powershell.exe -NoProfile -ExecutionPolicy Bypass -File tools\Run-RdpMonitorTests.ps1
#>
[CmdletBinding()]
param()
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$testsDir = Join-Path $PSScriptRoot 'tests'
$suites = @(
'Test-ScriptSyntaxAll.ps1',
'Test-TaskQueryModule.ps1',
'Test-DeployTaskLimit.ps1',
'Test-SecurityPollCursor.ps1',
'Test-SendDeploySacNotice.ps1'
)
Write-Host '=== RDP-login-monitor autotests ==='
$failed = 0
foreach ($suite in $suites) {
$path = Join-Path $testsDir $suite
if (-not (Test-Path -LiteralPath $path)) {
Write-Host "FAIL: missing suite $path"
$failed++
continue
}
Write-Host "--- $suite ---"
try {
& $path
} catch {
Write-Host "SUITE FAILED: $suite - $($_.Exception.Message)"
$failed++
}
}
if ($failed -gt 0) {
Write-Host ('=== FAILED ({0} suite(s)) ===' -f $failed)
exit 1
}
Write-Host '=== ALL PASSED ==='
exit 0
+1 -1
View File
@@ -3,7 +3,7 @@
Просмотр недавних 4624 с полями для диагностики RDP-login-monitor.
.EXAMPLE
.\Show-Rdp4624Recent.ps1
.\Show-Rdp4624Recent.ps1 -Minutes 30 -User papatramp
.\Show-Rdp4624Recent.ps1 -Minutes 30 -User jdoe
#>
[CmdletBinding()]
param(
+8
View File
@@ -0,0 +1,8 @@
$errs = $null
[void][System.Management.Automation.Language.Parser]::ParseFile(
(Join-Path $PSScriptRoot '..\Deploy-LoginMonitor.ps1'),
[ref]$null,
[ref]$errs
)
if ($errs) { $errs | ForEach-Object { $_.ToString() }; exit 1 }
Write-Output 'OK'
+55
View File
@@ -0,0 +1,55 @@
# Проверка парсинга UserData/EventInfo для RD Gateway 303 (BytesReceived != ErrorCode).
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$sample303 = @'
<Event xmlns="http://schemas.microsoft.com/win/2004/08/events/event">
<System>
<Provider Name="Microsoft-Windows-TerminalServices-Gateway" />
<EventID>303</EventID>
<TimeCreated SystemTime="2026-06-02T23:51:21.033855700Z" />
</System>
<UserData>
<EventInfo xmlns="aag">
<Username>CONTOSO\TSA</Username>
<IpAddress>95.154.72.73</IpAddress>
<Resource>192.168.164.43</Resource>
<BytesReceived>1991</BytesReceived>
<BytesTransfered>2116</BytesTransfered>
<SessionDuration>0</SessionDuration>
<ConnectionProtocol>HTTP</ConnectionProtocol>
<ErrorCode>1226</ErrorCode>
</EventInfo>
</UserData>
</Event>
'@
function Get-RDGatewayUserDataEventInfoMapFromXmlText {
param([string]$XmlText)
$map = @{}
$xml = [xml]$XmlText
$eventInfo = $xml.Event.UserData.EventInfo
foreach ($node in @($eventInfo.ChildNodes)) {
if ($null -eq $node -or $node.NodeType -ne [System.Xml.XmlNodeType]::Element) { continue }
$map[$node.LocalName] = [string]$node.InnerText
}
return $map
}
$map = Get-RDGatewayUserDataEventInfoMapFromXmlText -XmlText $sample303
if ($map['ErrorCode'] -ne '1226') {
throw "Expected ErrorCode=1226, got $($map['ErrorCode'])"
}
if ($map['BytesReceived'] -ne '1991') {
throw "Expected BytesReceived=1991, got $($map['BytesReceived'])"
}
if ($map['SessionDuration'] -ne '0') {
throw "Expected SessionDuration=0, got $($map['SessionDuration'])"
}
Write-Host 'OK: RD Gateway EventInfo XML fields parsed correctly (ErrorCode != BytesReceived).'
# 1226 в sample — типичный штатный код закрытия туннеля (в Login_Monitor.ps1 → disconnected, не failed).
if ($map['ErrorCode'] -ne '1226') {
throw 'Expected sample ErrorCode 1226 for standard RDG disconnect'
}
Write-Host 'OK: sample 303 ErrorCode 1226 (standard RD Gateway disconnect).'
+33
View File
@@ -0,0 +1,33 @@
. (Join-Path $PSScriptRoot '_TestLib.ps1')
. (Join-Path $PSScriptRoot '_DeployFunctionsLoader.ps1')
$repo = Get-RdpMonitorRepoRoot
Invoke-RdpMonitorTestCase -Name 'Deploy functions load (RDP_DEPLOY_FUNCTIONS_ONLY)' -Script {
Assert-CommandExists -Name 'Initialize-RdpMonitorDeployTaskQuery'
Assert-CommandExists -Name 'Test-RdpMonitorDeployMainTaskNeedsUnlimitedExecutionTime'
Assert-CommandExists -Name 'Write-RdpMonitorDeployScheduledTaskVerification'
Assert-CommandExists -Name 'Get-RdpMonitorDeployTaskExecutionTimeLimitLabelFromResolved'
Assert-CommandExists -Name 'Convert-RdpMonitorDeployTaskExecutionTimeLimitValue'
}
Invoke-RdpMonitorTestCase -Name 'Deploy ExecutionTimeLimit accepts PT0S string (Get-ScheduledTask shape)' -Script {
Assert-True -Condition (Test-RdpMonitorDeployTaskExecutionLimitUnlimitedValue -Limit 'PT0S') `
-Message 'PT0S string must be treated as unlimited'
$resolved = [pscustomobject]@{ Limit = 'PT0S'; Source = 'Get-ScheduledTask' }
$label = Get-RdpMonitorDeployTaskExecutionTimeLimitLabelFromResolved -Resolved $resolved
Assert-True -Condition ($label -eq 'PT0S') -Message "Expected PT0S label, got $label"
Assert-True -Condition (Test-RdpMonitorDeployTaskExecutionLimitUnlimitedValue -Limit $resolved.Limit) `
-Message 'Resolved PT0S string must pass unlimited check'
}
Invoke-RdpMonitorTestCase -Name 'Deploy pre-check task limit (no throw on early path)' -Script {
$needsFix = Test-RdpMonitorDeployMainTaskNeedsUnlimitedExecutionTime -ShareRoot $repo -TaskName 'RDP-Login-Monitor-UnitTest-Missing'
Assert-True -Condition ($needsFix -is [bool]) -Message 'Test-RdpMonitorDeployMainTaskNeedsUnlimitedExecutionTime must return bool'
}
Invoke-RdpMonitorTestCase -Name 'Deploy verification after Initialize (no missing command)' -Script {
[void](Initialize-RdpMonitorDeployTaskQuery -ShareRoot $repo)
Assert-CommandExists -Name 'Get-RdpMonitorScheduledTaskExecutionTimeLimitResolved'
$ok = Write-RdpMonitorDeployScheduledTaskVerification -ShareRoot $repo -TaskName 'RDP-Login-Monitor-UnitTest-Missing'
Assert-True -Condition ($ok -is [bool]) -Message 'Write-RdpMonitorDeployScheduledTaskVerification must return bool'
}
+22
View File
@@ -0,0 +1,22 @@
. (Join-Path $PSScriptRoot '_TestLib.ps1')
$repo = Get-RdpMonitorRepoRoot
$files = @(
'Deploy-LoginMonitor.ps1',
'Login_Monitor.ps1',
'Sac-Client.ps1',
'RdpMonitor-TaskQuery.ps1',
'update-rdp-monitor.ps1'
)
foreach ($rel in $files) {
$path = Join-Path $repo $rel
Invoke-RdpMonitorTestCase -Name "Syntax: $rel" -Script {
Assert-True -Condition (Test-Path -LiteralPath $path) -Message "Missing $path"
$errs = $null
[void][System.Management.Automation.Language.Parser]::ParseFile($path, [ref]$null, [ref]$errs)
if ($errs -and $errs.Count -gt 0) {
throw ($errs | ForEach-Object { $_.ToString() } | Out-String)
}
}
}
+49
View File
@@ -0,0 +1,49 @@
. (Join-Path $PSScriptRoot '_TestLib.ps1')
function Test-RdpSecurityPollCursorResolve {
param(
[datetime]$Now,
[int]$MaxAgeMinutes,
[Nullable[datetime]]$SavedCursor
)
$maxAgeMin = [math]::Max(1, $MaxAgeMinutes)
$lookbackFloor = $Now.AddMinutes(-1 * $maxAgeMin)
if ($null -eq $SavedCursor) {
return $lookbackFloor
}
if ($SavedCursor -lt $lookbackFloor) {
return $lookbackFloor
}
return $SavedCursor
}
Invoke-RdpMonitorTestCase -Name 'Security cursor: missing file uses lookback floor' -Script {
$now = Get-Date '2026-06-15T12:00:00'
$resolved = Test-RdpSecurityPollCursorResolve -Now $now -MaxAgeMinutes 60 -SavedCursor $null
$expected = $now.AddMinutes(-60)
Assert-True -Condition ($resolved -eq $expected) -Message 'Expected lookback floor when cursor missing'
}
Invoke-RdpMonitorTestCase -Name 'Security cursor: stale saved cursor capped to lookback floor' -Script {
$now = Get-Date '2026-06-15T12:00:00'
$stale = $now.AddMinutes(-120)
$resolved = Test-RdpSecurityPollCursorResolve -Now $now -MaxAgeMinutes 60 -SavedCursor $stale
$expected = $now.AddMinutes(-60)
Assert-True -Condition ($resolved -eq $expected) -Message 'Expected cap at lookback floor for stale cursor'
}
Invoke-RdpMonitorTestCase -Name 'Security cursor: recent saved cursor preserved' -Script {
$now = Get-Date '2026-06-15T12:00:00'
$recent = $now.AddMinutes(-5)
$resolved = Test-RdpSecurityPollCursorResolve -Now $now -MaxAgeMinutes 60 -SavedCursor $recent
Assert-True -Condition ($resolved -eq $recent) -Message 'Expected recent cursor unchanged'
}
Invoke-RdpMonitorTestCase -Name 'Login_Monitor defines Security poll cursor helpers' -Script {
$repo = Get-RdpMonitorRepoRoot
$text = Get-Content -LiteralPath (Join-Path $repo 'Login_Monitor.ps1') -Raw
Assert-True -Condition ($text -match 'Get-RdpSecurityPollCursor') -Message 'Missing Get-RdpSecurityPollCursor'
Assert-True -Condition ($text -match 'Set-RdpSecurityPollCursor') -Message 'Missing Set-RdpSecurityPollCursor'
Assert-True -Condition ($text -match '\$SecurityPollCursorFile') -Message 'Missing SecurityPollCursorFile'
}
+75
View File
@@ -0,0 +1,75 @@
. (Join-Path $PSScriptRoot '_TestLib.ps1')
function Test-RdpMonitorDeploySacHostLabelStrictMode {
Set-StrictMode -Version Latest
$hostLabel = [string]$env:COMPUTERNAME
if (Get-Variable -Name ServerDisplayName -Scope Script -ErrorAction SilentlyContinue) {
$sdn = (Get-Variable -Name ServerDisplayName -Scope Script -ValueOnly)
if ($null -ne $sdn -and -not [string]::IsNullOrWhiteSpace([string]$sdn)) {
$hostLabel = [string]$sdn.Trim()
}
}
return $hostLabel
}
Invoke-RdpMonitorTestCase -Name 'Deploy SAC host label without ServerDisplayName (StrictMode)' -Script {
$label = Test-RdpMonitorDeploySacHostLabelStrictMode
Assert-True -Condition (-not [string]::IsNullOrWhiteSpace($label)) -Message 'Host label must not be empty'
Assert-True -Condition ($label -eq [string]$env:COMPUTERNAME) -Message 'Expected COMPUTERNAME when ServerDisplayName unset'
}
Invoke-RdpMonitorTestCase -Name 'Deploy SAC host label with ServerDisplayName (StrictMode)' -Script {
$script:ServerDisplayName = 'Test-Server-Display'
try {
$label = Test-RdpMonitorDeploySacHostLabelStrictMode
Assert-True -Condition ($label -eq 'Test-Server-Display') -Message 'Expected ServerDisplayName value'
} finally {
Remove-Variable -Name ServerDisplayName -Scope Script -ErrorAction SilentlyContinue
}
}
Invoke-RdpMonitorTestCase -Name 'Login_Monitor -SendDeploySacNotice does not fail on StrictMode host label' -Script {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
Write-Host 'SKIP: requires elevated PowerShell (administrator)'
return
}
$repo = Get-RdpMonitorRepoRoot
$tempRoot = Join-Path $env:TEMP ("rdp-monitor-test-{0}" -f [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $tempRoot -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $tempRoot 'Logs') -Force | Out-Null
$settings = @'
$UseSAC = 'off'
'@
$settingsPath = Join-Path $tempRoot 'login_monitor.settings.ps1'
[System.IO.File]::WriteAllText($settingsPath, $settings, (New-Object System.Text.UTF8Encoding $true))
Copy-Item -LiteralPath (Join-Path $repo 'Login_Monitor.ps1') -Destination (Join-Path $tempRoot 'Login_Monitor.ps1') -Force
Copy-Item -LiteralPath (Join-Path $repo 'Sac-Client.ps1') -Destination (Join-Path $tempRoot 'Sac-Client.ps1') -Force
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe"
$psi.Arguments = '-NoProfile -ExecutionPolicy Bypass -File "{0}" -SendDeploySacNotice' -f (Join-Path $tempRoot 'Login_Monitor.ps1')
$psi.WorkingDirectory = $tempRoot
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$proc = [System.Diagnostics.Process]::Start($psi)
$stdout = $proc.StandardOutput.ReadToEnd()
$stderr = $proc.StandardError.ReadToEnd()
$proc.WaitForExit()
try {
if ($stderr -match 'ServerDisplayName|VariableIsUndefined') {
throw "SendDeploySacNotice stderr contains StrictMode error: $stderr"
}
Assert-True -Condition ($proc.ExitCode -eq 0) -Message "Expected exit 0 with UseSAC=off, got $($proc.ExitCode); stderr=$stderr stdout=$stdout"
} finally {
Remove-Item -LiteralPath $tempRoot -Recurse -Force -ErrorAction SilentlyContinue
}
}
+15
View File
@@ -0,0 +1,15 @@
. (Join-Path $PSScriptRoot '_TestLib.ps1')
$repo = Get-RdpMonitorRepoRoot
$taskQuery = Join-Path $repo 'RdpMonitor-TaskQuery.ps1'
Invoke-RdpMonitorTestCase -Name 'TaskQuery file exists' -Script {
Assert-True -Condition (Test-Path -LiteralPath $taskQuery) -Message "Missing $taskQuery"
}
Invoke-RdpMonitorTestCase -Name 'TaskQuery dot-source defines core commands' -Script {
. $taskQuery
Assert-CommandExists -Name 'Get-RdpMonitorScheduledTaskExecutionTimeLimitResolved'
Assert-CommandExists -Name 'Test-RdpMonitorScheduledTaskNeedsUnlimitedExecutionTimeLimit'
Assert-CommandExists -Name 'Get-RdpMonitorScheduledTaskExecutionTimeLimitLabel'
}
+16
View File
@@ -0,0 +1,16 @@
# Dot-source from a test .ps1 at script scope after _TestLib.ps1 (not from a function/scriptblock).
if ($script:RdpMonitorDeployFunctionsLoaded) { return }
$repo = Get-RdpMonitorRepoRoot
$prev = $env:RDP_DEPLOY_FUNCTIONS_ONLY
$env:RDP_DEPLOY_FUNCTIONS_ONLY = '1'
try {
. (Join-Path $repo 'Deploy-LoginMonitor.ps1')
} finally {
if ($null -eq $prev) {
Remove-Item Env:RDP_DEPLOY_FUNCTIONS_ONLY -ErrorAction SilentlyContinue
} else {
$env:RDP_DEPLOY_FUNCTIONS_ONLY = $prev
}
}
$script:RdpMonitorDeployFunctionsLoaded = $true
+39
View File
@@ -0,0 +1,39 @@
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$script:RdpMonitorDeployFunctionsLoaded = $false
function Get-RdpMonitorRepoRoot { return (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
}
function Assert-True {
param(
[Parameter(Mandatory = $true)][bool]$Condition,
[Parameter(Mandatory = $true)][string]$Message
)
if (-not $Condition) {
throw "FAIL: $Message"
}
}
function Assert-CommandExists {
param(
[Parameter(Mandatory = $true)][string]$Name
)
$cmd = Get-Command -Name $Name -ErrorAction SilentlyContinue
Assert-True -Condition ($null -ne $cmd) -Message "Command not found: $Name"
}
function Invoke-RdpMonitorTestCase {
param(
[Parameter(Mandatory = $true)][string]$Name,
[Parameter(Mandatory = $true)][scriptblock]$Script
)
try {
& $Script
Write-Host "PASS: $Name"
} catch {
Write-Host "FAIL: $Name - $($_.Exception.Message)"
throw
}
}
+6 -3
View File
@@ -1,8 +1,9 @@
<#
<#
.SYNOPSIS
Obnovlyaet klon RDP-login-monitor s git.kalinamall.ru i kopiruet dist na NETLOGON.
Obnovlyaet klon RDP-login-monitor s upstream git i kopiruet dist na NETLOGON.
.DESCRIPTION
Dlya servera publikatsii (napr. DC3). Remote: git.kalinamall.ru (kalinamall).
Dlya servera publikatsii (napr. DC3). Po umolchaniyu GitHub (github.com/PTah).
Na zakrytom zerkale ukazhite -GitUrl URL vashego Gitea.
Posle fetch: vsegda reset --hard na kalinamall/main (bez merge), zatem clean -fd.
Kopiruyutsya: polnyj spisok v Docs/deploy-netlogon-publish.md.
.EXAMPLE
@@ -24,6 +25,7 @@ $ErrorActionPreference = 'Stop'
$DistFiles = @(
'Login_Monitor.ps1',
'Sac-Client.ps1',
'RdpMonitor-TaskQuery.ps1',
'version.txt',
'Deploy-LoginMonitor.ps1',
'Restart-RdpLoginMonitor.ps1',
@@ -32,6 +34,7 @@ $DistFiles = @(
'Install-DomainMonitors.ps1',
'Deploy-DomainMonitors.ps1',
'exchange_monitor.settings.example.ps1',
'Diagnose-RdpLoginMonitor.ps1',
'login_monitor.settings.example.ps1'
)
+1 -1
View File
@@ -1 +1 @@
1.2.18-SAC
2.1.15-SAC