chore(home): mirror from kalinamall (038363c) with papatramp URLs

This commit is contained in:
2026-07-14 20:44:01 +10:00
commit 6d102603c2
46 changed files with 12612 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
root = true
[*]
charset = utf-8
end_of_line = crlf
insert_final_newline = true
[*.ps1]
charset = utf-8-bom
end_of_line = crlf
+2
View File
@@ -0,0 +1,2 @@
*.ps1 text eol=crlf
+8
View File
@@ -0,0 +1,8 @@
.cursor/
tools/*.log
*.log
*.bak
Logs/
sac-spool/
login_monitor.settings.ps1
exchange_monitor.settings.ps1
+11
View File
@@ -0,0 +1,11 @@
# Развёртывание
Документация перенесена в каталог **[Docs/](Docs/README.md)**:
| Руководство | Файл |
|-------------|------|
| RDP Login Monitor (NETLOGON, GPO) | [Docs/deploy-rdp-login-monitor.md](Docs/deploy-rdp-login-monitor.md) |
| Exchange Mail Security | [Docs/exchange-mail-security.md](Docs/exchange-mail-security.md) |
| Публикация на шару | [Docs/deploy-netlogon-publish.md](Docs/deploy-netlogon-publish.md) |
Оглавление: **[Docs/README.md](Docs/README.md)**
+167
View File
@@ -0,0 +1,167 @@
<#
.SYNOPSIS
Deploy domain monitor scripts (Exchange / AD) from share to ProgramData.
.PARAMETER Target
Exchange - Exchange-MailSecurity.ps1 + Notify-Common.ps1; Ad - reserved.
#>
[CmdletBinding()]
param(
[ValidateSet('Exchange', 'Ad')]
[Parameter(Mandatory = $true)]
[string]$Target,
[string]$SourceShareRoot = '',
[switch]$WhatIf,
[switch]$SkipInstallTasks,
[switch]$AllowDowngrade
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$InstallRoot = [System.IO.Path]::GetFullPath("$env:ProgramData\RDP-login-monitor")
$VersionStampPath = Join-Path $InstallRoot 'deployed_domain_monitors_version.txt'
$DeployLogPath = Join-Path $InstallRoot 'Logs\deploy_domain_monitors.log'
$VersionFileName = 'version.txt'
$Utf8Bom = New-Object System.Text.UTF8Encoding $true
$PsExe = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe"
$ExchangeFiles = @(
'Exchange-MailSecurity.ps1',
'Notify-Common.ps1',
'Install-DomainMonitors.ps1',
'exchange_monitor.settings.example.ps1'
)
function Write-DeployLog {
param([string]$Message)
$line = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') - [$Target] $Message" + [Environment]::NewLine
try {
$dir = Split-Path $DeployLogPath -Parent
if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
[System.IO.File]::AppendAllText($DeployLogPath, $line, $Utf8Bom)
} catch { }
Write-Host $line.TrimEnd("`r`n")
}
function Write-TextFileUtf8Bom {
param([string]$Path, [string]$Text)
[System.IO.File]::WriteAllText($Path, $Text, $Utf8Bom)
}
function Copy-ScriptFileWithUtf8Bom {
param(
[Parameter(Mandatory = $true)][string]$SourcePath,
[Parameter(Mandatory = $true)][string]$DestPath
)
$raw = [System.IO.File]::ReadAllBytes($SourcePath)
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
$text = $utf8NoBom.GetString($raw)
if ($text.Length -gt 0 -and [int][char]$text[0] -eq 0xFEFF) {
$text = $text.Substring(1)
}
[System.IO.File]::WriteAllText($DestPath, $text, $Utf8Bom)
}
function Resolve-SourceShareRoot {
if (-not [string]::IsNullOrWhiteSpace($SourceShareRoot)) {
return [System.IO.Path]::GetFullPath($SourceShareRoot.TrimEnd('\'))
}
$here = $PSCommandPath
if ([string]::IsNullOrWhiteSpace($here)) { $here = $MyInvocation.MyCommand.Path }
if ([string]::IsNullOrWhiteSpace($here)) {
throw 'Specify -SourceShareRoot or run this script by full UNC path to the .ps1 file on the share.'
}
return [System.IO.Path]::GetFullPath((Split-Path -Parent $here))
}
function Read-VersionLineFromFile {
param([string]$Path)
if (-not (Test-Path -LiteralPath $Path)) { return $null }
$raw = (Get-Content -LiteralPath $Path -TotalCount 3 -ErrorAction Stop | Where-Object { $_ -match '\S' }) | Select-Object -First 1
if ($null -eq $raw) { return $null }
return ([string]$raw).Trim() -replace '^v', ''
}
function Compare-SemVerLike {
param([string]$A, [string]$B)
try {
$va = [version]$A
$vb = [version]$B
return $va.CompareTo($vb)
} catch {
return [string]::Compare($A, $B, [StringComparison]::OrdinalIgnoreCase)
}
}
$shareRoot = Resolve-SourceShareRoot
$filesToCopy = switch ($Target) {
'Exchange' { $ExchangeFiles }
'Ad' { throw 'Target Ad is not supported yet.' }
}
$sourceVersionFile = Join-Path $shareRoot $VersionFileName
if (-not (Test-Path -LiteralPath $sourceVersionFile)) {
Write-DeployLog "ERROR: missing on share: $sourceVersionFile"
exit 1
}
$shareVer = Read-VersionLineFromFile -Path $sourceVersionFile
$localVer = Read-VersionLineFromFile -Path $VersionStampPath
Write-DeployLog "Share version=$shareVer local stamp=$localVer"
if (-not [string]::IsNullOrWhiteSpace($localVer)) {
$cmp = Compare-SemVerLike -A $shareVer -B $localVer
if ($cmp -eq 0) {
Write-DeployLog 'Version match - skip copy.'
if (-not $SkipInstallTasks) {
Write-DeployLog 'Running InstallTasks check...'
if (-not $WhatIf) {
& $PsExe -NoProfile -ExecutionPolicy Bypass -File (Join-Path $InstallRoot 'Install-DomainMonitors.ps1') -Target $Target
}
}
exit 0
}
if ($cmp -lt 0 -and -not $AllowDowngrade) {
Write-DeployLog 'Share version older than local - skip (use -AllowDowngrade to force).'
exit 0
}
}
if (-not (Test-Path -LiteralPath $InstallRoot)) {
if ($WhatIf) { Write-DeployLog "WhatIf: create $InstallRoot"; exit 0 }
New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null
}
foreach ($name in $filesToCopy) {
$src = Join-Path $shareRoot $name
$dst = Join-Path $InstallRoot $name
if (-not (Test-Path -LiteralPath $src)) {
Write-DeployLog "ERROR: missing on share: $name"
exit 1
}
if ($WhatIf) {
Write-DeployLog "WhatIf: copy $src -> $dst"
continue
}
if ($name -like '*.ps1') {
Copy-ScriptFileWithUtf8Bom -SourcePath $src -DestPath $dst
} else {
Copy-Item -LiteralPath $src -Destination $dst -Force
}
Write-DeployLog "Copied: $name"
}
if (-not $WhatIf) {
Write-TextFileUtf8Bom -Path $VersionStampPath -Text $shareVer
if (-not $SkipInstallTasks) {
$installer = Join-Path $InstallRoot 'Install-DomainMonitors.ps1'
& $PsExe -NoProfile -ExecutionPolicy Bypass -File $installer -Target $Target
if ($LASTEXITCODE -ne 0) {
Write-DeployLog "Install-DomainMonitors exit code: $LASTEXITCODE"
exit $LASTEXITCODE
}
}
}
Write-DeployLog 'Deploy-DomainMonitors finished.'
exit 0
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
+13
View File
@@ -0,0 +1,13 @@
# Документация RDP-login-monitor
Руководства по развёртыванию и эксплуатации скриптов мониторинга.
| Документ | Описание |
|----------|----------|
| [deploy-rdp-login-monitor.md](deploy-rdp-login-monitor.md) | **Login_Monitor.ps1** — NETLOGON, GPO, задачи планировщика, версии, 4740 на КД |
| [exchange-mail-security.md](exchange-mail-security.md) | **Exchange-MailSecurity.ps1** — очереди, пересылка на внешние адреса, установка |
| [deploy-netlogon-publish.md](deploy-netlogon-publish.md) | Публикация пакета на шару (`update-rdp-monitor.ps1`) |
Общий каталог установки на серверах: **`C:\ProgramData\RDP-login-monitor\`**.
Краткий обзор возможностей — в корневом **[README.md](../README.md)**.
+81
View File
@@ -0,0 +1,81 @@
# Публикация пакета на NETLOGON
Скрипт **`update-rdp-monitor.ps1`** на сервере публикации (например DC3) выполняет `git pull` и копирует файлы в шару.
## Путь на шаре (`-NetlogonDest`)
После `git pull` скрипт копирует дистрибутив в UNC-каталог NETLOGON:
```text
\\<имя-DC>\NETLOGON\RDP-login-monitor
```
`<имя-DC>` — NetBIOS-имя или FQDN **контроллера домена**, где лежит SYSVOL (тот же хост, с которого GPO запускает `Deploy-LoginMonitor.ps1`). Примеры:
- `\\K6A-DC3\NETLOGON\RDP-login-monitor`
- `\\dc01.corp.example.com\NETLOGON\RDP-login-monitor`
Значение по умолчанию в скрипте — **заглушка** `\\dc.contoso.local\NETLOGON\RDP-login-monitor`. В реальном домене её **нужно переопределить**, иначе после успешного `git pull` будет ошибка **«Не найден сетевой путь»** (скрипт не достучится до несуществующего хоста).
Проверка перед публикацией:
```powershell
Test-Path '\\K6A-DC3\NETLOGON\RDP-login-monitor'
# или хотя бы корень шары:
Test-Path '\\K6A-DC3\NETLOGON'
```
Должно вернуть `True` под учётной записью, с которой запускаете публикацию (на DC — обычно локальный админ; с рабочей станции — доменный админ с доступом к NETLOGON).
## Параметры по умолчанию
| Параметр | Значение |
|----------|----------|
| `$RepoPath` | `C:\Soft\Git\RDP-login-monitor` |
| `$NetlogonDest` | `\\dc.contoso.local\NETLOGON\RDP-login-monitor` *(заглушка — замените на свой DC)* |
| `$GitUrl` | `https://git.papatramp.ru/PapaTramp/RDP-login-monitor.git` |
| `$GitBranch` | `main` |
| `$LogFile` | `C:\soft\Logs\update-rdp-monitor.log` |
## Копируемые файлы
- `Login_Monitor.ps1`
- `Sac-Client.ps1`
- `version.txt`
- `Deploy-LoginMonitor.ps1`
- `Restart-RdpLoginMonitor.ps1`
- `Exchange-MailSecurity.ps1`
- `Notify-Common.ps1`
- `Install-DomainMonitors.ps1`
- `Deploy-DomainMonitors.ps1`
- `exchange_monitor.settings.example.ps1`
- `login_monitor.settings.example.ps1`
## Запуск
**На сервере публикации** (клон репозитория + доступ к NETLOGON):
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\Soft\Git\RDP-login-monitor\update-rdp-monitor.ps1 `
-NetlogonDest '\\K6A-DC3\NETLOGON\RDP-login-monitor'
```
Другой remote git (закрытое зеркало):
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\Soft\Git\RDP-login-monitor\update-rdp-monitor.ps1 `
-NetlogonDest '\\K6A-DC3\NETLOGON\RDP-login-monitor' `
-GitUrl 'https://git.papatramp.ru/PapaTramp/RDP-login-monitor.git'
```
Пробный прогон без копирования:
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\Soft\Git\RDP-login-monitor\update-rdp-monitor.ps1 `
-NetlogonDest '\\K6A-DC3\NETLOGON\RDP-login-monitor' `
-WhatIf
```
После pull обязательно проверьте **`version.txt`** на шаре — его номер определяет, подтянут ли обновления на клиентах. Метка может включать суффикс (например **`1.2.27-SAC`**); **`Deploy-LoginMonitor.ps1`** сравнивает её с **`deployed_version.txt`** по полной строке.
Файлы **`*.ps1`** при копировании пересохраняются как **UTF-8 с BOM** (иначе PowerShell 5.1 с NETLOGON может не разобрать кириллицу в скриптах). Скрипты **`Deploy-DomainMonitors.ps1`** и **`Install-DomainMonitors.ps1`** используют **ASCII** в рабочих строках — их можно запускать и без перепубликации.
+237
View File
@@ -0,0 +1,237 @@
# Развёртывание RDP Login Monitor в домене
Монитор ставится в **`C:\ProgramData\RDP-login-monitor\`**, задачи планировщика создаёт **`Login_Monitor.ps1 -InstallTasks`**. Доставку по сети выполняет **`Deploy-LoginMonitor.ps1`**.
- Публикация на NETLOGON: [deploy-netlogon-publish.md](deploy-netlogon-publish.md)
- Exchange Mail Security (отдельный пакет): [exchange-mail-security.md](exchange-mail-security.md)
## Файлы на файловой шаре
Каталог, доступный **конечным компьютерам** на чтение (учётная запись компьютера домена / SYSTEM), например:
`\\dc.contoso.local\NETLOGON\RDP-login-monitor\`
| Файл | Назначение |
|------|------------|
| `Login_Monitor.ps1` | Основной скрипт мониторинга |
| `Sac-Client.ps1` | Клиент SAC (обязателен для SAC; сверка SHA256 при деплое) |
| `login_monitor.settings.example.ps1` | Образец настроек (bootstrap локального settings) |
| `version.txt` | **Одна строка** — версия пакета на шаре (например `1.2.27-SAC`) |
| `Deploy-LoginMonitor.ps1` | Установщик для GPO / scheduled task |
Полный список файлов на шару (включая Exchange): [deploy-netlogon-publish.md](deploy-netlogon-publish.md).
**Не копируются Deploy-LoginMonitor:** `ignore.lst`, `login_monitor.settings.ps1` (секреты локальны).
## Алгоритм Deploy (GPO startup / scheduled task)
```mermaid
flowchart TD
A[GPO startup или RDP-Login-Monitor-Deploy] --> B[Deploy-LoginMonitor.ps1 с UNC]
B --> C{Login_Monitor.ps1 и version.txt на шаре?}
C -->|нет| Z[exit 0, запись в deploy.log]
C -->|да| D[Сравнение version.txt vs deployed_version.txt]
D --> E{Нужна работа?}
E -->|версия совпадает, bundle OK, settings OK| F["Актуально — exit 0"]
E -->|новая версия / патчи / первый запуск| G[Graceful stop монитора]
G --> H[Copy Login_Monitor.ps1 + Sac-Client.ps1]
H --> I[Sync settings из example + post-patches]
I --> J[Login_Monitor.ps1 -InstallTasks]
J --> K[deployed_version.txt + deploy_last_update.txt]
K --> L[Start monitor]
```
Корень дистрибутива: **`-SourceShareRoot`** или родитель каталога `Deploy-LoginMonitor.ps1` при запуске по UNC.
Локальная метка версии: **`C:\ProgramData\RDP-login-monitor\deployed_version.txt`**. Если файла нет — версия берётся из **`$ScriptVersion`** в установленном `Login_Monitor.ps1`.
### Когда деплой **не** копирует файлы
Версия на шаре **совпадает** с локальной **и** одновременно:
- SHA256 **`Login_Monitor.ps1`** и **`Sac-Client.ps1`** совпадает с шарой;
- в **`login_monitor.settings.ps1`** уже есть настроенный SAC (или не требуется bootstrap);
- применены post-patches: подсказки **`$ServerDisplayName`**, **`$DailyReportEnabled`**, на Exchange — noise settings.
Иначе деплой **продолжается** (даже при совпадении номера версии): другой hash bundle, донастройка SAC, исправление `DailyReportEnabled = false` без `$`, дописывание Exchange-фильтров и т.д.
### Upgrade / downgrade
| Ситуация | Поведение |
|----------|-----------|
| Версия на шаре **новее** | Полный цикл деплоя |
| Версия **совпадает**, но нужны патчи | Полный цикл (см. выше) |
| Версия на шаре **старее** | Пропуск, пока не указан **`-AllowDowngrade`** |
Метки с суффиксом (**`1.2.27-SAC`**) сравниваются **по полной строке**; для порядка версий используется числовой префикс `1.2.27`.
Лог: **`C:\ProgramData\RDP-login-monitor\Logs\deploy.log`**. Ошибки пишутся в лог и завершаются **`exit 0`** (удобно для GPO).
## Сервер Exchange и GPO Deploy-LoginMonitor
**`Deploy-LoginMonitor.ps1` не копирует отдельный Exchange-пакет** — на MailServer те же **`Login_Monitor.ps1`** + **`Sac-Client.ps1`**, что на RDS и рабочих станциях.
При обнаружении роли Exchange (реестр, каталог установки, службы) Deploy **только дописывает** в **`login_monitor.settings.ps1`**, если строк ещё нет:
- `${Ignore4624-LT3-EmptyIP-Event} = $true`
- `$WinRmIgnoreLocalSource = 1`
- `$WinRmIgnoreMachineAccounts = 1`
- `$WinRmExchangeStrictMode = 1`
Подавление шума WinRM (HealthMailbox, loopback), ложных WinRM 91↔4624 (Outlook/LT3) и лишних **4624 LT3**.
**Мониторинг очередей транспорта и правил пересылки** — другой скрипт, **не** через GPO RDP-монитора:
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "\\dc.contoso.local\NETLOGON\RDP-login-monitor\Deploy-DomainMonitors.ps1" -Target Exchange
```
Подробно: [exchange-mail-security.md](exchange-mail-security.md).
## Локальные настройки: `login_monitor.settings.ps1`
Путь: **`C:\ProgramData\RDP-login-monitor\login_monitor.settings.ps1`**.
| Действие Deploy | Условие |
|-----------------|--------|
| Копирует example → settings | Файла settings ещё нет |
| Дописывает блок SAC из example | Нет `$UseSAC` / `$SacUrl` / `$SacApiKey` |
| **Не перезаписывает** целиком | SAC уже настроен (`UseSAC` не `off`, ключ и URL заданы) |
| Подсказка `# $ServerDisplayName = '<COMPUTERNAME>'` | Строки `$ServerDisplayName` нет |
| Подсказка / починка `$DailyReportEnabled` | Нет переменной или `= false` без `$``$false` |
| Exchange noise (см. выше) | Роль Exchange и нет соответствующих строк |
Секреты (Telegram, **`sac_*`**, SMTP, 4740) правятся **вручную** на машине.
```powershell
$root = 'C:\ProgramData\RDP-login-monitor'
Copy-Item '\\dc.contoso.local\NETLOGON\RDP-login-monitor\login_monitor.settings.example.ps1' `
(Join-Path $root 'login_monitor.settings.ps1')
notepad (Join-Path $root 'login_monitor.settings.ps1')
```
DPAPI: **`Encrypt-DpapiForRdpMonitor.ps1`**.
### Security Alert Center
```powershell
$UseSAC = 'dual' # off | exclusive | dual | fallback
$SacUrl = 'https://sac.example.com'
$SacApiKey = 'sac_...'
```
Проверка: **`Login_Monitor.ps1 -CheckSac`**. После правок settings — **`Restart-RdpLoginMonitor.ps1`** или **`-RequestRestart`**.
## Задачи, heartbeat, ignore.lst
| Задача | Назначение |
|--------|------------|
| **`RDP-Login-Monitor`** | Запуск при старте ОС |
| **`RDP-Login-Monitor-Watchdog`** | Контроль процесса каждые 5 мин |
Логи: **`login_monitor.log`**, **`watchdog.log`**, **`Logs\last_heartbeat.txt`**.
**`ignore.lst`** — локально, Deploy не копирует. Синтаксис: [README.md](../README.md) (раздел 7).
**4740 на КД:** только если имя узла совпадает с **`$LockoutMonitorDomainController`** в settings на этом КД.
## GPO и периодический deploy
1. Файлы на `\\dc.contoso.local\NETLOGON\RDP-login-monitor\` (`update-rdp-monitor.ps1` на DC публикации).
2. GPO на OU **компьютеров****Сценарии PowerShell** автозагрузки → `Deploy-LoginMonitor.ps1`.
3. Security Filtering: группа компьютеров; на шару — Read для **SYSTEM** / Domain Computers.
4. После смены membership — **перезагрузка** (не только `gpupdate`).
Deploy после `-InstallTasks` запускает монитор; задача **`RDP-Login-Monitor`** поднимет его при следующей загрузке.
**Серверы без частых перезагрузок:**
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\Install-DeployScheduledTask.ps1" `
-TaskName "RDP-Login-Monitor-Deploy" `
-DeployScriptPath "\\dc.contoso.local\NETLOGON\RDP-login-monitor\Deploy-LoginMonitor.ps1" `
-RepeatMinutes 60 `
-RunNow
```
**Ручной deploy** (от администратора):
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "\\dc.contoso.local\NETLOGON\RDP-login-monitor\Deploy-LoginMonitor.ps1"
```
Параметры: **`-WhatIf`**, **`-SkipStartMonitorAfterUpdate`**, **`-AllowDowngrade`**.
## Обновление: чеклист
### A. Публикация на шару (один раз на релиз)
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\soft\update-rdp-monitor.ps1
```
Проверьте на шаре **`Sac-Client.ps1`** и **`version.txt`** (например `1.2.27-SAC`).
### B. На целевых машинах
GPO или scheduled task — дождаться startup / `-RunNow`. Ручная проверка:
```powershell
Get-Content 'C:\ProgramData\RDP-login-monitor\Logs\deploy.log' -Tail 20
Select-String -Path 'C:\ProgramData\RDP-login-monitor\Login_Monitor.ps1' -Pattern 'ScriptVersion'
Test-Path 'C:\ProgramData\RDP-login-monitor\Sac-Client.ps1'
Get-ScheduledTask -TaskName 'RDP-Login-Monitor','RDP-Login-Monitor-Watchdog' -ErrorAction SilentlyContinue
```
Повторный запуск Deploy без смены версии должен дать **`Актуально, копирование не требуется`**.
### C. Graceful restart
После правки settings (без смены `Login_Monitor.ps1`):
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass `
-File "C:\ProgramData\RDP-login-monitor\Login_Monitor.ps1" -RequestRestart
```
После Deploy нового **`Login_Monitor.ps1`** — recycle:
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass `
-File "C:\ProgramData\RDP-login-monitor\Login_Monitor.ps1" -RequestRestart -Recycle
```
**`Deploy-LoginMonitor.ps1`** пишет **`restart.request`**, ждёт до **35 с**, затем **`Stop-Process -Force`** только при таймауте.
## Обновление через SAC (WinRM)
Альтернатива NETLOGON/GPO: кнопка **«Обновить через WinRM»** на карточке хоста в SAC (сервер ≥ 0.20.15).
| Этап | Где | Действие |
|------|-----|----------|
| 1 | SAC | `git fetch` RDP-login-monitor → zip (`.ps1` с UTF-8 BOM) |
| 2 | Клиент (WinRM) | `Invoke-WebRequest``/api/v1/agent/rdp-bundle/<token>` |
| 3 | Клиент | Распаковка в `C:\ProgramData\RDP-login-monitor\_sac_staging` |
| 4 | Клиент | `Deploy-LoginMonitor.ps1 -SourceShareRoot _sac_staging` |
**Требования:** domain admin в SAC; `SAC_PUBLIC_URL` доступен с ПК; `rdp_git_repo_url` в настройках обновлений. **`Deploy-LoginMonitor.ps1` на шаре NETLOGON для этого пути не обязателен** — скрипт приходит в zip.
Лог операции — в модалке SAC сразу при старте. См. [agent-control-plane.md](https://git.papatramp.ru/PapaTramp/security-alert-center/src/branch/main/docs/agent-control-plane.md) §4.3.
## Версии
| Файл | Роль |
|------|------|
| **`version.txt` на шаре** | Триггер обновления для Deploy |
| **`deployed_version.txt` локально** | Метка после успешного деплоя |
| **`$ScriptVersion` в Login_Monitor.ps1** | Версия в логах и Telegram |
Поднимайте **`version.txt`** при каждой выкладке на NETLOGON; строка в **`version.txt`** и **`$ScriptVersion`** должны совпадать (включая суффикс `-SAC`).
## Безопасность и UNC
- Ограничьте ACL на шару (в example могут быть доменные секреты).
- DPAPI для токенов в **`login_monitor.settings.ps1`**.
- При ошибках подписи с FQDN-шары: короткое имя DC и **`-ExecutionPolicy Bypass`**.
Диагностика: Group Policy Operational log, **`Logs\deploy.log`**, **`deploy_installtasks_*.log`**.
+268
View File
@@ -0,0 +1,268 @@
# Exchange Mail Security — руководство
Скрипт **`Exchange-MailSecurity.ps1`** предназначен **только для сервера Microsoft Exchange** с Exchange Management Shell (EMS). Не устанавливается на все компьютеры домена через GPO RDP-монитора.
На Exchange-сервере GPO **`Deploy-LoginMonitor.ps1`** по-прежнему ставит **`Login_Monitor.ps1`** (RDP/WinRM/4624) и при необходимости дописывает noise settings в **`login_monitor.settings.ps1`**. Пакет **`Exchange-MailSecurity.ps1`** доставляется отдельно — см. раздел «Деплой на Exchange» ниже и [deploy-rdp-login-monitor.md](deploy-rdp-login-monitor.md).
## Назначение
| Функция | Режим | Расписание (задача планировщика) |
|---------|--------|----------------------------------|
| **Очереди транспорта** | `-Mode Queues` | `RDP-Exchange-TransportQueues` — каждые **10** мин |
| **Пересылка на внешние адреса** (BEC) | `-Mode Inbox` | `RDP-Exchange-InboxRules` — ежедневно **02:00** |
| **Контроль heartbeat** | `-Watchdog` | `RDP-Exchange-MailSecurity-Watchdog` — каждые **15** мин |
Каталог установки (общий с RDP-монитором): **`C:\ProgramData\RDP-login-monitor\`**.
## Что считается «несанкционированной пересылкой»
Скрипт ищет доставку почты **на домены вне организации** (не из списка принятых доменов Exchange).
### 1. Правила Inbox (`Get-InboxRule`)
Для **включённых** правил проверяются:
- `ForwardTo`
- `RedirectTo`
- `ForwardAsAttachmentTo`
Если целевой SMTP-адрес указывает на домен **не** из `Get-AcceptedDomain` (типы **Authoritative** и **InternalRelay**) и не в **whitelist** — фиксируется находка.
**Повышенная важность (Критическая):** включённое правило с `DeleteMessage` или `MarkAsRead`.
**Отключённые правила** (`$ScanDisabledInboxRulesWithExternalForward = $true`): внешняя пересылка фиксируется с важностью **«Средняя (правило отключено)»**.
### 2. Пересылка на уровне ящика (`Get-Mailbox`)
Проверяются ящики с заполненными:
- `ForwardingSmtpAddress`
- `ForwardingAddress`
Это отдельный от Inbox Rule механизм («пересылать всю почту на внешний ящик») — частый вектор BEC.
### 3. Правила транспорта (`Get-TransportRule`)
Для **включённых** transport rules проверяются действия с адресами:
- `RedirectMessageTo`
- `BlindCopyTo`
- `CopyTo`
При внешнем SMTP — находка с типом **TransportRule**.
### Что не отслеживается
- Делегирование, Send As, скрытые EWS-правила без Inbox rule
- Пересылка только между **внутренними** accepted domains
- Чтение почты злоумышленником **без** настройки forward
## Очереди транспорта
`Get-Queue`: если **`MessageCount`** на очереди больше **`$QueueMessageCountThreshold`** (по умолчанию **150**), отправляется оповещение.
Повтор алерта по той же очереди — не чаще **`$QueueAlertCooldownSeconds`** (по умолчанию **900** с), состояние в `Logs\exchange_queue_alert_state.json`.
## Оповещения и baseline
- Каналы: **Telegram** и/или **Email** (модуль **`Notify-Common.ps1`**).
- Пересылка: **`$AlertOnlyOnNewForwardingFindings = $true`** — алерт при **новой** находке (`Logs\exchange_forwarding_baseline.json`).
- **Первый скан:** **`$SuppressAlertsOnFirstBaselineRun = $true`** (по умолчанию) — существующие пересылки **только в baseline**, без всплеска алертов; одна **сводка** (`$SendInboxScanSummary`).
### Dry-run перед первым Inbox-сканом
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "\\dc.contoso.local\NETLOGON\RDP-login-monitor\Exchange-MailSecurity.ps1" -Mode Inbox -WhatIf
```
`-WhatIf` подключает EMS, считает объём (`Get-Mailbox` / VIP-фильтр), **не вызывает** `Get-InboxRule`, не шлёт уведомления и не пишет baseline. Рекомендуется перед `-InstallTasks` и первым ночным `-Mode Inbox`.
При полном скане без VIP скрипт пишет **WARN** в лог; проблемные ящики — в **`$SkipInboxScanMailboxes`**.
- Далее — алерт только при **новых** или **изменённых** пересылках (в т.ч. включили ранее отключённое правило).
- **`$NotifyWhenForwardingScanClean = $false`** — не слать «всё чисто» при нуле находок.
## Режим VIP (пилот)
В **`exchange_monitor.settings.ps1`**:
```powershell
$VipMailboxesOnly = $true
$VipMailboxes = @('director@domain.ru', 'cfo@domain.ru')
$VipMailboxPatterns = @('*@domain.ru') # опционально
```
- **Список** — точные SMTP.
- **Шаблоны** — дополнительно обход `Get-Mailbox` и отбор по `-like`.
- Mailbox forwarding и Inbox rules — только в рамках VIP.
- Для полного домена: **`$VipMailboxesOnly = $false`**.
## Файлы на шаре NETLOGON
| Файл | Назначение |
|------|------------|
| `Exchange-MailSecurity.ps1` | Основной скрипт |
| `Notify-Common.ps1` | Отправка Telegram/Email |
| `Install-DomainMonitors.ps1` | `-Target Exchange` → регистрация задач |
| `Deploy-DomainMonitors.ps1` | Копирование с шары |
| `exchange_monitor.settings.example.ps1` | Образец настроек (на сервер — вручную) |
| `version.txt` | Версия пакета (общая с RDP-монитором) |
## Установка (пошагово)
### 1. Публикация на шару
На сервере публикации: `update-rdp-monitor.ps1` (см. [deploy-netlogon-publish.md](deploy-netlogon-publish.md)).
### 2. Деплой на Exchange (от администратора)
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "\\dc.contoso.local\NETLOGON\RDP-login-monitor\Deploy-DomainMonitors.ps1" -Target Exchange
```
Скрипт копирует файлы в `C:\ProgramData\RDP-login-monitor\`, вызывает **`Install-DomainMonitors.ps1 -Target Exchange`**, который регистрирует задачи планировщика.
Метка версии: `deployed_domain_monitors_version.txt`. Лог: `Logs\deploy_domain_monitors.log`.
### 3. Локальные настройки (один раз)
```powershell
# После Deploy v1.6.4+ образец лежит в ProgramData; иначе — с NETLOGON:
$ex = 'C:\ProgramData\RDP-login-monitor'
$src = Join-Path $ex 'exchange_monitor.settings.example.ps1'
if (-not (Test-Path -LiteralPath $src)) {
$src = '\\dc.contoso.local\NETLOGON\RDP-login-monitor\exchange_monitor.settings.example.ps1'
}
Copy-Item -LiteralPath $src -Destination (Join-Path $ex 'exchange_monitor.settings.ps1')
notepad (Join-Path $ex 'exchange_monitor.settings.ps1')
```
Задайте:
- `$TelegramBotToken` / `$TelegramChatID` (или DPAPI `...ProtectedB64`)
- при необходимости SMTP
- `$ExternalDomainWhitelist` — доверенные внешние домены
- для пилота: `$VipMailboxesOnly = $true` и `$VipMailboxes = @('user@domain.ru')`
Файл **`exchange_monitor.settings.ps1` не перезаписывается** при деплое.
### 4. Права
Учётная запись **SYSTEM** (задачи планировщика) должна иметь возможность:
- загрузить EMS на сервере;
- выполнять `Get-Mailbox`, `Get-InboxRule`, `Get-Queue`, `Get-TransportRule`, `Get-AcceptedDomain`.
На практике на Exchange-сервере под SYSTEM это обычно работает при локальном snap-in; при ошибках RBAC — запуск задачи от служебной УЗ с правами просмотра получателей.
### 5. Удалённый EMS
Если скрипт запускается **не** на Exchange, укажите в settings:
```powershell
$ExchangeServerFqdn = 'mail.contoso.local'
```
(подключение через `New-PSSession -ConfigurationName Microsoft.Exchange`).
## Ручной запуск и проверка
```powershell
# Очереди (быстро)
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\RDP-login-monitor\Exchange-MailSecurity.ps1" -Mode Queues
# Полный скан пересылки (может занять часы на большом количестве ящиков)
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\RDP-login-monitor\Exchange-MailSecurity.ps1" -Mode Inbox
# Watchdog
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\RDP-login-monitor\Exchange-MailSecurity.ps1" -Watchdog
# Переустановка задач
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\RDP-login-monitor\Exchange-MailSecurity.ps1" -InstallTasks
```
Проверка задач:
```powershell
schtasks /Query /TN "RDP-Exchange-TransportQueues" /V /FO LIST
schtasks /Query /TN "RDP-Exchange-InboxRules" /V /FO LIST
schtasks /Query /TN "RDP-Exchange-MailSecurity-Watchdog" /V /FO LIST
```
## Логи и служебные файлы
| Путь | Назначение |
|------|------------|
| `Logs\exchange_mail_security.log` | Основной лог |
| `Logs\exchange_watchdog.log` | Watchdog |
| `Logs\last_exchange_queues_ok.txt` | Успешный скан очередей |
| `Logs\last_exchange_inbox_scan_ok.txt` | Успешный скан пересылки |
| `Logs\exchange_forwarding_baseline.json` | Baseline находок (id правил/пересылок) |
| `Logs\exchange_queue_alert_state.json` | Cooldown алертов по очередям |
### Watchdog
- Нет обновления **очередей** > **20** мин (`$QueuesHeartbeatStaleSeconds`) → алерт.
- Нет обновления **inbox scan** > **26** ч (`$InboxHeartbeatStaleSeconds`) → алерт.
## Основные параметры в `Exchange-MailSecurity.ps1`
| Параметр | По умолчанию | Смысл |
|----------|--------------|--------|
| `$QueueMessageCountThreshold` | 150 | Порог сообщений в очереди |
| `$QueueAlertCooldownSeconds` | 900 | Пауза между алертами по одной очереди |
| `$ScanInboxRules` | `$true` | Скан Inbox rules |
| `$ScanMailboxForwarding` | `$true` | Скан forwarding на ящике |
| `$ScanTransportRules` | `$true` | Скан transport rules |
| `$AlertOnlyOnNewForwardingFindings` | `$true` | Только новые находки |
| `$VipMailboxesOnly` | `$false` | Только VIP-ящики |
| `$VipMailboxPatterns` | `@()` | Wildcard для VIP |
| `$SuppressAlertsOnFirstBaselineRun` | `$true` | Первый скан без N алертов |
| `$ScanDisabledInboxRulesWithExternalForward` | `$true` | Отключённые правила с внешним forward |
| `$SendInboxScanSummary` | `$true` | Сводка после скана |
| `$InboxScanBatchSize` | 50 | Пауза каждые N ящиков |
| `$InboxScanBatchDelaySeconds` | 3 | Задержка между батчами |
| `$ExcludeMailboxPatterns` | HealthMailbox*, … | Исключения |
| `$SkipInboxScanMailboxes` | `k.selezneva@example.com` | Не вызывать `Get-InboxRule` (битый rule store) |
Переопределение — в **`exchange_monitor.settings.ps1`**.
После починки ящика удалите его из `$SkipInboxScanMailboxes`.
## Формат оповещения (пересылка)
Пример:
```text
📧 Exchange: пересылка на внешний адрес
Тип: InboxRule | MailboxForwarding | TransportRule
Ящик: user@example.com
Правило: …
Куда: attacker@gmail.com (внешний)
Важность: Критическая | Высокая
```
## Обновление версии
1. Поднять **`version.txt`** в репозитории и на шаре.
2. `update-rdp-monitor.ps1` или ручное копирование.
3. На Exchange:
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "\\dc.contoso.local\NETLOGON\RDP-login-monitor\Deploy-DomainMonitors.ps1" -Target Exchange
```
## Устранение неполадок
| Симптом | Действие |
|---------|----------|
| «Не удалось загрузить Exchange Management Shell» | Запуск на mailbox/Exchange server; проверить snap-in / `$ExchangeServerFqdn` |
| Нет алертов | Проверить `exchange_monitor.settings.ps1`, Telegram/SMTP, лог `exchange_mail_security.log` |
| Слишком много алертов в первый день | По умолчанию подавлены (`$SuppressAlertsOnFirstBaselineRun`); иначе whitelist + baseline |
| Inbox scan долгий | `$VipMailboxesOnly = $true`; увеличить `$InboxScanBatchDelaySeconds` |
| Ошибки Get-InboxRule на ящике | В логе будет «ошибка mailbox» — штатно, скан продолжается |
## Связанные документы
- [deploy-rdp-login-monitor.md](deploy-rdp-login-monitor.md) — RDP-монитор на ПК/серверах
- [deploy-netlogon-publish.md](deploy-netlogon-publish.md) — выкладка на шару
- [README.md](README.md) — оглавление Docs
+12
View File
@@ -0,0 +1,12 @@
# Запуск: от администратора на ТОМ ЖЕ компьютере, где будет Login_Monitor.ps1.
# Результат (Base64) вставьте в login_monitor.settings.ps1 или exchange_monitor.settings.ps1:
# $TelegramBotTokenProtectedB64 / $TelegramChatIDProtectedB64 / $MailSmtpPasswordProtectedB64.
param(
[Parameter(Mandatory = $true)][string]$PlainText
)
Add-Type -AssemblyName System.Security
$bytes = [Text.Encoding]::UTF8.GetBytes($PlainText)
$protected = [System.Security.Cryptography.ProtectedData]::Protect(
$bytes, $null, [System.Security.Cryptography.DataProtectionScope]::LocalMachine
)
[Convert]::ToBase64String($protected)
+935
View File
@@ -0,0 +1,935 @@
<#
.SYNOPSIS
Мониторинг Exchange: очереди транспорта, пересылка на внешние адреса (Inbox rules + mailbox forwarding + transport rules).
.DESCRIPTION
Запуск на сервере с Exchange Management Shell (локальный snap-in или remote).
Режимы: -Mode Queues (каждые 10 мин по задаче), -Mode Inbox (ежедневный скан), -Watchdog (проверка heartbeat).
Установка задач: -InstallTasks
.NOTES
Каталог: C:\ProgramData\RDP-login-monitor\
Опционально: exchange_monitor.settings.ps1 в том же каталоге (секреты, whitelist).
#>
[CmdletBinding(SupportsShouldProcess = $true)]
param(
[ValidateSet('Queues', 'Inbox', 'Watchdog')]
[string]$Mode = 'Queues',
[switch]$InstallTasks,
[switch]$Watchdog,
[switch]$WhatIf
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# ============================================
# КОНФИГУРАЦИЯ
# ============================================
$ScriptVersion = '1.6.8'
$script:InstallRoot = [System.IO.Path]::GetFullPath("$env:ProgramData\RDP-login-monitor")
$script:CanonicalScriptName = 'Exchange-MailSecurity.ps1'
$LogFile = Join-Path $script:InstallRoot 'Logs\exchange_mail_security.log'
$WatchdogLogFile = Join-Path $script:InstallRoot 'Logs\exchange_watchdog.log'
$QueuesHeartbeatFile = Join-Path $script:InstallRoot 'Logs\last_exchange_queues_ok.txt'
$InboxHeartbeatFile = Join-Path $script:InstallRoot 'Logs\last_exchange_inbox_scan_ok.txt'
$ForwardingBaselineFile = Join-Path $script:InstallRoot 'Logs\exchange_forwarding_baseline.json'
$QueueAlertStateFile = Join-Path $script:InstallRoot 'Logs\exchange_queue_alert_state.json'
# --- Уведомления (как Login_Monitor.ps1; можно вынести в exchange_monitor.settings.ps1) ---
$NotifyOrder = 'tg'
$TelegramBotToken = ''
$TelegramChatID = ''
$TelegramBotTokenProtectedB64 = ''
$TelegramChatIDProtectedB64 = ''
$MailSmtpHost = ''
$MailSmtpPort = 587
$MailSmtpUser = ''
$MailSmtpPassword = ''
$MailSmtpPasswordProtectedB64 = ''
$MailFrom = ''
$MailTo = ''
$MailSmtpStartTls = $true
$MailSmtpSsl = $false
# --- Exchange / сканирование ---
$ExchangeServerFqdn = '' # пусто = локальный snap-in на этом сервере
$QueueMessageCountThreshold = 150
$QueueAlertCooldownSeconds = 900
$ExternalDomainWhitelist = @() # partner-bank.ru
$AlertOnlyOnNewForwardingFindings = $true
$ScanMailboxForwarding = $true
$ScanInboxRules = $true
$ScanTransportRules = $true
# VIP: только перечисленные ящики и/или шаблоны (пилот перед полным сканом).
$VipMailboxesOnly = $false
$VipMailboxes = @() # точные PrimarySmtpAddress: user@domain.com
$VipMailboxPatterns = @() # wildcard: *@example.com, director*, finance*
$ExcludeMailboxPatterns = @('HealthMailbox*', 'DiscoveryMailbox*', 'SystemMailbox*')
# Skip Inbox rules scan (corrupt rule store / Get-InboxRule fails). Override in exchange_monitor.settings.ps1
$SkipInboxScanMailboxes = @(
'broken-mailbox@example.com'
)
$ScanDisabledInboxRulesWithExternalForward = $true # отключённые правила с внешней пересылкой
$SuppressAlertsOnFirstBaselineRun = $true # первый скан: baseline без всплеска алертов
$SendInboxScanSummary = $true # краткая сводка после скана
$InboxScanBatchSize = 50
$InboxScanBatchDelaySeconds = 3
$MaxMailboxesPerRun = 0 # 0 = без лимита
$QueuesHeartbeatStaleSeconds = 1200 # 20 мин
$InboxHeartbeatStaleSeconds = 93600 # 26 ч
$NotifyWhenForwardingScanClean = $false # true = ежедневное «внешняя пересылка не найдена»
$script:ScheduledTaskQueues = 'RDP-Exchange-TransportQueues'
$script:ScheduledTaskInbox = 'RDP-Exchange-InboxRules'
$script:ScheduledTaskWatchdog = 'RDP-Exchange-MailSecurity-Watchdog'
# ============================================
# ИНИЦИАЛИЗАЦИЯ
# ============================================
if (!(Test-Path -LiteralPath $script:InstallRoot)) {
New-Item -ItemType Directory -Path $script:InstallRoot -Force | Out-Null
}
$LogDir = Split-Path $LogFile -Parent
if (!(Test-Path $LogDir)) { New-Item -ItemType Directory -Path $LogDir -Force | Out-Null }
$script:Utf8BomEncoding = New-Object System.Text.UTF8Encoding $true
$SettingsFile = Join-Path $script:InstallRoot 'exchange_monitor.settings.ps1'
if (Test-Path -LiteralPath $SettingsFile) {
. $SettingsFile
}
function Write-ExchangeScanSafetyWarnings {
if (-not $SuppressAlertsOnFirstBaselineRun) {
Write-ExchLog 'WARN: SuppressAlertsOnFirstBaselineRun=$false — первый Inbox-скан может разослать алерты по всем уже существующим пересылкам.'
}
if (-not $VipMailboxesOnly -and $MaxMailboxesPerRun -le 0 -and $ScanInboxRules) {
Write-ExchLog 'WARN: полный скан Inbox rules по всем ящикам (VipMailboxesOnly=$false). Рекомендуется пилот: VipMailboxesOnly=$true или -Mode Inbox -WhatIf.'
}
if (@($SkipInboxScanMailboxes | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }).Count -eq 0) {
Write-ExchLog 'TIP: добавьте проблемные ящики в $SkipInboxScanMailboxes, если Get-InboxRule падает (corrupt rule store).'
}
}
function Write-NotifyLog {
param([string]$Message)
Write-ExchLog $Message
}
function Write-ExchLog {
param([string]$Message)
$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
$logMessage = "$timestamp - $Message" + [Environment]::NewLine
[System.IO.File]::AppendAllText($LogFile, $logMessage, $script:Utf8BomEncoding)
Write-Host ($logMessage.TrimEnd("`r`n"))
}
function Write-WdLog {
param([string]$Message)
$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
$line = "$timestamp - $Message" + [Environment]::NewLine
[System.IO.File]::AppendAllText($WatchdogLogFile, $line, $script:Utf8BomEncoding)
Write-Host ($line.TrimEnd("`r`n"))
}
function Test-RunningElevated {
try {
$id = [Security.Principal.WindowsIdentity]::GetCurrent()
if ($null -ne $id.User -and $id.User.Value -eq 'S-1-5-18') { return $true }
$p = New-Object Security.Principal.WindowsPrincipal($id)
return $p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
} catch { return $false }
}
function Get-ExchangeMonitorScriptPath {
$p = $PSCommandPath
if ([string]::IsNullOrWhiteSpace($p)) { $p = $MyInvocation.MyCommand.Path }
if ([string]::IsNullOrWhiteSpace($p)) { return (Join-Path $script:InstallRoot $script:CanonicalScriptName) }
return [System.IO.Path]::GetFullPath($p)
}
function Get-ExchangeMonitorPowerShellExe {
return "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe"
}
# --- InstallTasks / Watchdog до dot-source уведомлений ---
function Register-ExchangeMonitorScheduledTasks {
$psExe = Get-ExchangeMonitorPowerShellExe
$scriptPath = Get-ExchangeMonitorScriptPath
$argQueues = "-NoProfile -ExecutionPolicy Bypass -File `"$scriptPath`" -Mode Queues"
$argInbox = "-NoProfile -ExecutionPolicy Bypass -File `"$scriptPath`" -Mode Inbox"
$argWd = "-NoProfile -ExecutionPolicy Bypass -File `"$scriptPath`" -Watchdog"
$schtasksExe = Join-Path $env:SystemRoot 'System32\schtasks.exe'
$delEa = $ErrorActionPreference
$ErrorActionPreference = 'SilentlyContinue'
foreach ($tn in @($script:ScheduledTaskQueues, $script:ScheduledTaskInbox, $script:ScheduledTaskWatchdog)) {
& $schtasksExe /Delete /TN $tn /F 2>$null | Out-Null
}
$ErrorActionPreference = $delEa
$trQueues = "`"$psExe`" $argQueues"
$outQ = & $schtasksExe /Create /F /RU SYSTEM /RL HIGHEST /SC MINUTE /MO 10 /TN $script:ScheduledTaskQueues /TR $trQueues 2>&1
foreach ($line in @($outQ)) { Write-ExchLog "schtasks queues: $line" }
$trInbox = "`"$psExe`" $argInbox"
$outI = & $schtasksExe /Create /F /RU SYSTEM /RL HIGHEST /SC DAILY /ST 02:00 /TN $script:ScheduledTaskInbox /TR $trInbox 2>&1
foreach ($line in @($outI)) { Write-ExchLog "schtasks inbox: $line" }
$trWd = "`"$psExe`" $argWd"
$outW = & $schtasksExe /Create /F /RU SYSTEM /RL HIGHEST /SC MINUTE /MO 15 /TN $script:ScheduledTaskWatchdog /TR $trWd 2>&1
foreach ($line in @($outW)) { Write-ExchLog "schtasks watchdog: $line" }
$ErrorActionPreference = 'SilentlyContinue'
& $schtasksExe /Run /TN $script:ScheduledTaskQueues 2>&1 | Out-Null
& $schtasksExe /Run /TN $script:ScheduledTaskWatchdog 2>&1 | Out-Null
$ErrorActionPreference = 'Stop'
Write-ExchLog "InstallTasks: registered $($script:ScheduledTaskQueues), $($script:ScheduledTaskInbox), $($script:ScheduledTaskWatchdog)"
}
if ($Watchdog) { $Mode = 'Watchdog' }
# Уведомления
$notifyPath = Join-Path $script:InstallRoot 'Notify-Common.ps1'
if (-not (Test-Path -LiteralPath $notifyPath)) {
$notifyPath = Join-Path (Split-Path -Parent (Get-ExchangeMonitorScriptPath)) 'Notify-Common.ps1'
}
if (-not (Test-Path -LiteralPath $notifyPath)) {
Write-Host "Notify-Common.ps1 not found near $script:InstallRoot"
exit 1
}
. $notifyPath
$tgToken = [ref]$TelegramBotToken
$tgChat = [ref]$TelegramChatID
$mailPass = [ref]$MailSmtpPassword
Initialize-NotifyCredentials -TelegramBotTokenProtectedB64 $TelegramBotTokenProtectedB64 `
-TelegramChatIDProtectedB64 $TelegramChatIDProtectedB64 `
-TelegramBotToken $tgToken -TelegramChatID $tgChat `
-MailSmtpPasswordProtectedB64 $MailSmtpPasswordProtectedB64 -MailSmtpPassword $mailPass
if ($TelegramBotToken -eq '<TELEGRAM_BOT_TOKEN>') { $TelegramBotToken = '' }
if ($TelegramChatID -eq '<TELEGRAM_CHAT_ID>') { $TelegramChatID = '' }
function Get-ExchangeInboxScanScopeLabel {
if (-not $VipMailboxesOnly) {
if ($MaxMailboxesPerRun -gt 0) { return "all mailboxes (limit $MaxMailboxesPerRun)" }
return 'all mailboxes'
}
$n = @($VipMailboxes | Where-Object { $_ }).Count
$p = @($VipMailboxPatterns | Where-Object { $_ }).Count
return "VIP list=$n patterns=$p"
}
function Send-ExchangeInstallNotification {
$scope = Get-ExchangeInboxScanScopeLabel
$vipNote = if ($VipMailboxesOnly) { "VIP on ($scope)" } else { 'VIP off (full scan)' }
$firstScan = if ($SuppressAlertsOnFirstBaselineRun) { 'baseline, no alert flood on first run' } else { 'alert on all findings' }
$msg = "<b>Exchange Mail Security installed</b>`r`n"
$msg += "Host: $(ConvertTo-TelegramHtml $env:COMPUTERNAME) | v$ScriptVersion`r`n"
$msg += "Notify: $(ConvertTo-TelegramHtml (Get-NotifyChainHuman))`r`n"
$msg += "Inbox/forward: $vipNote`r`n"
$msg += "Queues threshold: $QueueMessageCountThreshold | Inbox 02:00 | Queues every 10 min`r`n"
$msg += "First scan: $firstScan"
Send-MonitorNotification -Message $msg -EmailSubject 'Exchange Mail Security: install' | Out-Null
}
if ($InstallTasks) {
if (-not (Test-RunningElevated)) {
Write-Host 'InstallTasks: run as Administrator.'
exit 1
}
Register-ExchangeMonitorScheduledTasks
if ((Test-NotifyTelegramConfigured) -or (Test-NotifyEmailConfigured)) {
Send-ExchangeInstallNotification
Write-ExchLog 'InstallTasks: install notification sent'
}
Write-ExchLog 'InstallTasks: перед первым ночным Inbox-сканом выполните: Exchange-MailSecurity.ps1 -Mode Inbox -WhatIf'
exit 0
}
# ============================================
# EXCHANGE EMS
# ============================================
$script:ExchangeSessionLoaded = $false
function Import-ExchangeManagementShell {
if ($script:ExchangeSessionLoaded) { return }
$snapins = @(Get-PSSnapin -Registered -ErrorAction SilentlyContinue | Where-Object {
$_.Name -match 'Microsoft\.Exchange\.Management\.PowerShell'
})
if ($snapins.Count -gt 0) {
Add-PSSnapin -Name $snapins[0].Name -ErrorAction Stop
$script:ExchangeSessionLoaded = $true
Write-ExchLog "Exchange: snap-in $($snapins[0].Name)"
return
}
$rex = Join-Path ${env:ExchangeInstallPath} 'bin\RemoteExchange.ps1'
if ([string]::IsNullOrWhiteSpace(${env:ExchangeInstallPath})) {
$candidates = @(
'C:\Program Files\Microsoft\Exchange Server\V15\bin\RemoteExchange.ps1',
'C:\Program Files\Microsoft\Exchange Server\V14\bin\RemoteExchange.ps1'
)
foreach ($c in $candidates) {
if (Test-Path -LiteralPath $c) { $rex = $c; break }
}
}
if (Test-Path -LiteralPath $rex) {
. $rex
Import-ExchangeCmdlet -DisableNameVerifying -ErrorAction Stop
$script:ExchangeSessionLoaded = $true
Write-ExchLog "Exchange: RemoteExchange $rex"
return
}
if (-not [string]::IsNullOrWhiteSpace($ExchangeServerFqdn)) {
$uri = "http://$ExchangeServerFqdn/PowerShell/"
$session = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri $uri -Authentication Kerberos -ErrorAction Stop
Import-PSSession $session -DisableNameChecking -AllowClobber -ErrorAction Stop | Out-Null
$script:ExchangeSessionLoaded = $true
Write-ExchLog "Exchange: PSSession $uri"
return
}
throw 'Failed to load Exchange Management Shell (snap-in / RemoteExchange / set $ExchangeServerFqdn).'
}
# ============================================
# ВНЕШНИЕ ДОМЕНЫ
# ============================================
function Get-InternalAcceptedDomainNames {
$null = Import-ExchangeManagementShell
$names = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
$domains = Get-AcceptedDomain -ErrorAction Stop
foreach ($d in $domains) {
if ($d.DomainType -eq 'Authoritative' -or $d.DomainType -eq 'InternalRelay') {
$null = $names.Add([string]$d.DomainName)
}
}
return $names
}
function Get-EmailDomainFromAddress {
param([string]$Address)
if ([string]::IsNullOrWhiteSpace($Address)) { return $null }
$a = $Address.Trim().ToLowerInvariant()
if ($a -match 'smtp:([^@\s]+@([^@\s;]+))') { return $matches[2] }
if ($a -match '([a-z0-9._%+-]+@([a-z0-9.-]+\.[a-z]{2,}))') { return $matches[2] }
return $null
}
function Get-AddressesFromExchangeProperty {
param($PropertyValue)
$list = [System.Collections.Generic.List[string]]::new()
if ($null -eq $PropertyValue) { return @() }
foreach ($item in @($PropertyValue)) {
$s = [string]$item
if ([string]::IsNullOrWhiteSpace($s)) { continue }
if ($s -match 'smtp:([^;\s]+@[^;\s]+)') {
$list.Add($matches[1].ToLowerInvariant()) | Out-Null
} elseif ($s -match '([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})') {
$list.Add($matches[1].ToLowerInvariant()) | Out-Null
}
}
return @($list)
}
function Test-IsExternalSmtpAddress {
param(
[string]$SmtpAddress,
[System.Collections.Generic.HashSet[string]]$InternalDomains,
[string[]]$WhitelistDomains
)
$domain = Get-EmailDomainFromAddress -Address $SmtpAddress
if ([string]::IsNullOrWhiteSpace($domain)) { return $false }
foreach ($w in $WhitelistDomains) {
if ([string]::IsNullOrWhiteSpace($w)) { continue }
if ($domain -eq $w.Trim().ToLowerInvariant()) { return $false }
}
return -not $InternalDomains.Contains($domain)
}
function Test-MailboxExcluded {
param([string]$PrimarySmtp)
foreach ($pat in $ExcludeMailboxPatterns) {
if ([string]::IsNullOrWhiteSpace($pat)) { continue }
if ($PrimarySmtp -like $pat) { return $true }
}
return $false
}
function Test-MailboxSkipInboxScan {
param([string]$PrimarySmtp)
if ([string]::IsNullOrWhiteSpace($PrimarySmtp)) { return $false }
$smtp = $PrimarySmtp.Trim().ToLowerInvariant()
foreach ($skip in $SkipInboxScanMailboxes) {
if ([string]::IsNullOrWhiteSpace($skip)) { continue }
if ($smtp -eq $skip.Trim().ToLowerInvariant()) { return $true }
}
return $false
}
function Test-VipMailboxScopeConfigured {
if (-not $VipMailboxesOnly) { return $true }
$hasList = @($VipMailboxes | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }).Count -gt 0
$hasPat = @($VipMailboxPatterns | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }).Count -gt 0
return ($hasList -or $hasPat)
}
function Test-MailboxInVipScope {
param([string]$PrimarySmtp)
if (-not $VipMailboxesOnly) { return $true }
if (-not (Test-VipMailboxScopeConfigured)) { return $false }
$smtp = $PrimarySmtp.Trim().ToLowerInvariant()
foreach ($v in $VipMailboxes) {
if ([string]::IsNullOrWhiteSpace($v)) { continue }
if ($smtp -eq $v.Trim().ToLowerInvariant()) { return $true }
}
foreach ($pat in $VipMailboxPatterns) {
if ([string]::IsNullOrWhiteSpace($pat)) { continue }
if ($smtp -like $pat) { return $true }
}
return $false
}
# ============================================
# FINDINGS
# ============================================
function New-ForwardingFinding {
param(
[string]$FindingType,
[string]$Mailbox,
[string]$RuleName,
[string]$TargetAddress,
[string]$Severity,
[hashtable]$Extra = @{}
)
$rulePart = if ([string]::IsNullOrWhiteSpace($RuleName)) { '' } else { $RuleName }
$enabledPart = 'na'
if ($Extra.ContainsKey('Enabled')) {
$enabledPart = if ($Extra['Enabled']) { 'on' } else { 'off' }
}
$id = '{0}|{1}|{2}|{3}|{4}' -f $FindingType, $Mailbox.ToLowerInvariant(), $rulePart, $TargetAddress.ToLowerInvariant(), $enabledPart
return [pscustomobject]@{
Id = $id
FindingType = $FindingType
Mailbox = $Mailbox
RuleName = $RuleName
TargetAddress = $TargetAddress
Severity = $Severity
Extra = $Extra
}
}
function Format-ForwardingFindingMessage {
param($Finding)
$hType = ConvertTo-TelegramHtml $Finding.FindingType
$hMb = ConvertTo-TelegramHtml $Finding.Mailbox
$hRule = ConvertTo-TelegramHtml $Finding.RuleName
$hTarget = ConvertTo-TelegramHtml $Finding.TargetAddress
$hSev = ConvertTo-TelegramHtml $Finding.Severity
$hostName = ConvertTo-TelegramHtml $env:COMPUTERNAME
$msg = "<b>Exchange: external forward</b>`r`n"
$msg += "Host: $hostName`r`n"
$msg += "Type: $hType`r`n"
$msg += "Mailbox: $hMb`r`n"
if (-not [string]::IsNullOrWhiteSpace($Finding.RuleName)) {
$msg += "Rule: $hRule`r`n"
}
$msg += "Target: $hTarget (external)`r`n"
$msg += "Severity: $hSev`r`n"
if ($Finding.Extra.ContainsKey('Enabled')) {
$msg += "Enabled: $(ConvertTo-TelegramHtml ([string]$Finding.Extra['Enabled']))`r`n"
}
if ($Finding.Extra.ContainsKey('DeleteMessage')) {
$msg += "DeleteMessage: $(ConvertTo-TelegramHtml ([string]$Finding.Extra['DeleteMessage']))`r`n"
}
if ($Finding.Extra.ContainsKey('MarkAsRead')) {
$msg += "MarkAsRead: $(ConvertTo-TelegramHtml ([string]$Finding.Extra['MarkAsRead']))`r`n"
}
if ($Finding.Extra.ContainsKey('DeliverToMailboxAndForward')) {
$msg += "DeliverToMailboxAndForward: $(ConvertTo-TelegramHtml ([string]$Finding.Extra['DeliverToMailboxAndForward']))`r`n"
}
if ($Finding.Extra.ContainsKey('Property')) {
$msg += "Rule property: $(ConvertTo-TelegramHtml ([string]$Finding.Extra['Property']))`r`n"
}
return $msg
}
function Get-ForwardingBaseline {
if (-not (Test-Path -LiteralPath $ForwardingBaselineFile)) {
return @{ FindingIds = @(); LastScanUtc = $null }
}
try {
$raw = Get-Content -LiteralPath $ForwardingBaselineFile -Raw -Encoding UTF8 | ConvertFrom-Json
$ids = @()
if ($null -ne $raw.FindingIds) { $ids = @($raw.FindingIds) }
return @{ FindingIds = $ids; LastScanUtc = $raw.LastScanUtc }
} catch {
Write-ExchLog "Baseline: read failed ($($_.Exception.Message))"
return @{ FindingIds = @(); LastScanUtc = $null }
}
}
function Save-ForwardingBaseline {
param([string[]]$FindingIds)
$obj = @{
LastScanUtc = (Get-Date).ToUniversalTime().ToString('o')
FindingIds = @($FindingIds)
}
$json = $obj | ConvertTo-Json -Depth 4
Write-TextFileUtf8Bom -Path $ForwardingBaselineFile -Text $json
}
function Write-TextFileUtf8Bom {
param([string]$Path, [string]$Text)
[System.IO.File]::WriteAllText($Path, $Text, $script:Utf8BomEncoding)
}
# ============================================
# SCAN: QUEUES
# ============================================
function Get-QueueAlertState {
if (-not (Test-Path -LiteralPath $QueueAlertStateFile)) { return @{} }
try {
$h = @{}
(Get-Content -LiteralPath $QueueAlertStateFile -Raw -Encoding UTF8 | ConvertFrom-Json).PSObject.Properties | ForEach-Object {
$h[$_.Name] = [datetime]$_.Value
}
return $h
} catch { return @{} }
}
function Save-QueueAlertState {
param([hashtable]$State)
$obj = @{}
foreach ($k in $State.Keys) { $obj[$k] = $State[$k].ToUniversalTime().ToString('o') }
Write-TextFileUtf8Bom -Path $QueueAlertStateFile -Text ($obj | ConvertTo-Json)
}
function Invoke-ExchangeQueueScan {
$null = Import-ExchangeManagementShell
Write-ExchLog "Queues: threshold MessageCount > $QueueMessageCountThreshold"
$queues = @(Get-Queue -ErrorAction Stop)
if ($WhatIf) {
$hot = @($queues | Where-Object { $_.MessageCount -gt $QueueMessageCountThreshold })
Write-ExchLog "WhatIf: queues total=$($queues.Count), above threshold=$($hot.Count) — alerts skipped"
foreach ($q in $hot) {
Write-ExchLog "WhatIf: would alert queue=$($q.Identity) messages=$($q.MessageCount)"
}
return
}
$hot = @($queues | Where-Object { $_.MessageCount -gt $QueueMessageCountThreshold })
$state = Get-QueueAlertState
$now = Get-Date
if ($hot.Count -eq 0) {
Write-ExchLog "Queues: OK (total $($queues.Count), above threshold 0)"
Write-TextFileUtf8Bom -Path $QueuesHeartbeatFile -Text (Get-Date -Format 'yyyy-MM-dd HH:mm:ss')
return
}
foreach ($q in $hot) {
$qKey = [string]$q.Identity
$lastAlert = $null
if ($state.ContainsKey($qKey)) { $lastAlert = $state[$qKey] }
$cooldownOk = ($null -eq $lastAlert) -or (((Get-Date).ToUniversalTime() - $lastAlert.ToUniversalTime()).TotalSeconds -ge $QueueAlertCooldownSeconds)
if (-not $cooldownOk) {
Write-ExchLog "Queues: $($q.Identity) count=$($q.MessageCount) - cooldown, skip alert"
continue
}
$hId = ConvertTo-TelegramHtml $q.Identity
$msg = "<b>Exchange: transport queue</b>`r`n"
$msg += "Host: $(ConvertTo-TelegramHtml $env:COMPUTERNAME)`r`n"
$msg += "Queue: $hId`r`n"
$msg += "Messages: <b>$($q.MessageCount)</b> (threshold $QueueMessageCountThreshold)`r`n"
$msg += "Status: $(ConvertTo-TelegramHtml ([string]$q.Status))`r`n"
$msg += "Time: $(ConvertTo-TelegramHtml (Get-Date -Format 'dd.MM.yyyy HH:mm:ss'))"
if (Send-MonitorNotification -Message $msg -EmailSubject 'Exchange: transport queue') {
$state[$qKey] = $now
Write-ExchLog "Queues: alert sent for $($q.Identity)"
}
}
Save-QueueAlertState -State $state
Write-TextFileUtf8Bom -Path $QueuesHeartbeatFile -Text (Get-Date -Format 'yyyy-MM-dd HH:mm:ss')
}
# ============================================
# SCAN: FORWARDING
# ============================================
function Get-MailboxListForScan {
$null = Import-ExchangeManagementShell
if ($VipMailboxesOnly) {
if (-not (Test-VipMailboxScopeConfigured)) {
throw 'VipMailboxesOnly=$true but VipMailboxes and VipMailboxPatterns are empty - set them in exchange_monitor.settings.ps1'
}
$set = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
foreach ($v in $VipMailboxes) {
if ([string]::IsNullOrWhiteSpace($v)) { continue }
if (Test-MailboxSkipInboxScan -PrimarySmtp $v) { continue }
$null = $set.Add($v.Trim().ToLowerInvariant())
}
if ($VipMailboxPatterns.Count -gt 0) {
$all = @(Get-Mailbox -ResultSize Unlimited -ErrorAction Stop)
foreach ($mb in $all) {
$smtp = [string]$mb.PrimarySmtpAddress
if ([string]::IsNullOrWhiteSpace($smtp)) { continue }
if (Test-MailboxExcluded -PrimarySmtp $smtp) { continue }
if (Test-MailboxSkipInboxScan -PrimarySmtp $smtp) { continue }
if (Test-MailboxInVipScope -PrimarySmtp $smtp) {
$null = $set.Add($smtp.ToLowerInvariant())
}
}
}
return @($set)
}
$all = @(Get-Mailbox -ResultSize Unlimited -ErrorAction Stop)
$list = [System.Collections.Generic.List[string]]::new()
foreach ($mb in $all) {
$smtp = [string]$mb.PrimarySmtpAddress
if ([string]::IsNullOrWhiteSpace($smtp)) { continue }
if (Test-MailboxExcluded -PrimarySmtp $smtp) { continue }
if (Test-MailboxSkipInboxScan -PrimarySmtp $smtp) { continue }
$list.Add($smtp.ToLowerInvariant()) | Out-Null
if ($MaxMailboxesPerRun -gt 0 -and $list.Count -ge $MaxMailboxesPerRun) { break }
}
return @($list)
}
function Scan-MailboxForwardingSettings {
param(
[System.Collections.Generic.HashSet[string]]$InternalDomains,
[System.Collections.Generic.List[object]]$Findings
)
if (-not $ScanMailboxForwarding) { return }
Write-ExchLog 'Forwarding: Get-Mailbox (ForwardingSmtpAddress / ForwardingAddress)'
$mbs = Get-Mailbox -ResultSize Unlimited -ErrorAction Stop | Where-Object {
$_.ForwardingSmtpAddress -or $_.ForwardingAddress
}
foreach ($mb in $mbs) {
$primary = [string]$mb.PrimarySmtpAddress
if (Test-MailboxExcluded -PrimarySmtp $primary) { continue }
if (-not (Test-MailboxInVipScope -PrimarySmtp $primary)) { continue }
$targets = @()
if ($mb.ForwardingSmtpAddress) {
$targets += @($mb.ForwardingSmtpAddress)
}
if ($mb.ForwardingAddress) {
$targets += @((Get-AddressesFromExchangeProperty -PropertyValue $mb.ForwardingAddress))
}
foreach ($t in $targets) {
if (-not (Test-IsExternalSmtpAddress -SmtpAddress $t -InternalDomains $InternalDomains -WhitelistDomains $ExternalDomainWhitelist)) {
continue
}
$sev = 'High'
$Findings.Add((New-ForwardingFinding -FindingType 'MailboxForwarding' -Mailbox $primary `
-RuleName '' -TargetAddress $t -Severity $sev -Extra @{
DeliverToMailboxAndForward = [string]$mb.DeliverToMailboxAndForward
})) | Out-Null
}
}
}
function Add-InboxRuleExternalForwardFindings {
param(
$Rule,
[string]$Mailbox,
[System.Collections.Generic.HashSet[string]]$InternalDomains,
[System.Collections.Generic.List[object]]$Findings
)
$props = @(
@{ Name = 'ForwardTo'; Value = $Rule.ForwardTo }
@{ Name = 'RedirectTo'; Value = $Rule.RedirectTo }
@{ Name = 'ForwardAsAttachmentTo'; Value = $Rule.ForwardAsAttachmentTo }
)
foreach ($p in $props) {
$addrs = Get-AddressesFromExchangeProperty -PropertyValue $p.Value
foreach ($addr in $addrs) {
if (-not (Test-IsExternalSmtpAddress -SmtpAddress $addr -InternalDomains $InternalDomains -WhitelistDomains $ExternalDomainWhitelist)) {
continue
}
$sev = 'High'
if (-not $Rule.Enabled) {
$sev = 'Medium (rule disabled)'
} elseif ($Rule.DeleteMessage -or $Rule.MarkAsRead) {
$sev = 'Critical'
}
$Findings.Add((New-ForwardingFinding -FindingType 'InboxRule' -Mailbox $Mailbox `
-RuleName [string]$Rule.Name -TargetAddress $addr -Severity $sev -Extra @{
Enabled = [bool]$Rule.Enabled
DeleteMessage = $Rule.DeleteMessage
MarkAsRead = $Rule.MarkAsRead
Property = $p.Name
})) | Out-Null
}
}
}
function Scan-InboxRulesForMailbox {
param(
[string]$Mailbox,
[System.Collections.Generic.HashSet[string]]$InternalDomains,
[System.Collections.Generic.List[object]]$Findings
)
if (Test-MailboxSkipInboxScan -PrimarySmtp $Mailbox) {
Write-ExchLog "Inbox rules: SKIP $Mailbox (SkipInboxScanMailboxes)"
return
}
try {
$rules = @(Get-InboxRule -Mailbox $Mailbox -ErrorAction Stop)
} catch {
Write-ExchLog "Inbox rules: SKIP $Mailbox (Get-InboxRule failed: $($_.Exception.Message))"
return
}
foreach ($rule in $rules) {
if ($rule.Enabled) {
Add-InboxRuleExternalForwardFindings -Rule $rule -Mailbox $Mailbox -InternalDomains $InternalDomains -Findings $Findings
} elseif ($ScanDisabledInboxRulesWithExternalForward) {
Add-InboxRuleExternalForwardFindings -Rule $rule -Mailbox $Mailbox -InternalDomains $InternalDomains -Findings $Findings
}
}
}
function Scan-TransportRulesExternalForward {
param(
[System.Collections.Generic.HashSet[string]]$InternalDomains,
[System.Collections.Generic.List[object]]$Findings
)
if (-not $ScanTransportRules) { return }
Write-ExchLog 'Forwarding: Get-TransportRule'
$rules = @(Get-TransportRule -ErrorAction SilentlyContinue | Where-Object { $_.State -eq 'Enabled' })
foreach ($rule in $rules) {
$props = @(
$rule.RedirectMessageTo
$rule.BlindCopyTo
$rule.CopyTo
)
foreach ($pv in $props) {
$addrs = Get-AddressesFromExchangeProperty -PropertyValue $pv
foreach ($addr in $addrs) {
if (-not (Test-IsExternalSmtpAddress -SmtpAddress $addr -InternalDomains $InternalDomains -WhitelistDomains $ExternalDomainWhitelist)) {
continue
}
$Findings.Add((New-ForwardingFinding -FindingType 'TransportRule' -Mailbox '(transport)' `
-RuleName [string]$rule.Name -TargetAddress $addr -Severity 'High' -Extra @{})) | Out-Null
}
}
}
}
function Send-ExchangeInboxScanSummary {
param(
[int]$TotalFindings,
[int]$NewFindings,
[int]$MailboxCount,
[bool]$FirstBaselineSeeded,
[string]$ScopeLabel
)
if (-not $SendInboxScanSummary) { return }
$hHost = ConvertTo-TelegramHtml $env:COMPUTERNAME
$msg = "<b>Exchange: forwarding scan summary</b>`r`n"
$msg += "Host: $hHost | v$ScriptVersion`r`n"
$msg += "Scope: $(ConvertTo-TelegramHtml $ScopeLabel)`r`n"
$msg += "Mailboxes (Inbox rules): $MailboxCount`r`n"
$msg += "Findings total: $TotalFindings | new: $NewFindings`r`n"
if ($FirstBaselineSeeded) {
$msg += "<i>First baseline: existing forwards suppressed (SuppressAlertsOnFirstBaselineRun).</i>`r`n"
}
Send-MonitorNotification -Message $msg -EmailSubject 'Exchange: scan summary' | Out-Null
}
function Invoke-ExchangeInboxAndForwardingScan {
$scopeLabel = Get-ExchangeInboxScanScopeLabel
Write-ExchLog "Inbox/Forwarding scan v$ScriptVersion; scope: $scopeLabel; notify: $(Get-NotifyChainHuman)"
if ($WhatIf) {
$null = Import-ExchangeManagementShell
$internalDomains = Get-InternalAcceptedDomainNames
Write-ExchLog "WhatIf: accepted domains: $(@($internalDomains) -join ', ')"
$mailboxCount = 0
if ($ScanInboxRules) {
$mailboxes = @(Get-MailboxListForScan)
$mailboxCount = $mailboxes.Count
Write-ExchLog "WhatIf: would scan $mailboxCount mailboxes ($scopeLabel); Get-InboxRule not called"
}
Write-ExchLog "WhatIf: ScanMailboxForwarding=$ScanMailboxForwarding ScanTransportRules=$ScanTransportRules — no alerts, no baseline write"
return
}
$null = Import-ExchangeManagementShell
$internalDomains = Get-InternalAcceptedDomainNames
Write-ExchLog "Accepted domains (internal): $(@($internalDomains) -join ', ')"
$findings = [System.Collections.Generic.List[object]]::new()
Scan-MailboxForwardingSettings -InternalDomains $internalDomains -Findings $findings
$mailboxCount = 0
if ($ScanInboxRules) {
$mailboxes = @(Get-MailboxListForScan)
$mailboxCount = $mailboxes.Count
$skipList = @($SkipInboxScanMailboxes | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
if ($skipList.Count -gt 0) {
Write-ExchLog "Inbox rules: SkipInboxScanMailboxes: $($skipList -join ', ')"
}
Write-ExchLog "Inbox rules: mailboxes to scan: $mailboxCount ($scopeLabel)"
$idx = 0
foreach ($mb in $mailboxes) {
$idx++
Scan-InboxRulesForMailbox -Mailbox $mb -InternalDomains $internalDomains -Findings $findings
if ($idx % $InboxScanBatchSize -eq 0) {
Write-ExchLog "Inbox rules: processed $idx / $($mailboxes.Count)"
Start-Sleep -Seconds $InboxScanBatchDelaySeconds
}
}
}
Scan-TransportRulesExternalForward -InternalDomains $internalDomains -Findings $findings
$allIds = @($findings | ForEach-Object { $_.Id })
$baseline = Get-ForwardingBaseline
$prevSet = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
foreach ($id in $baseline.FindingIds) { $null = $prevSet.Add($id) }
$newFindings = @($findings | Where-Object { -not $prevSet.Contains($_.Id) })
Write-ExchLog "Forwarding: total $($findings.Count), new $($newFindings.Count)"
$isFirstBaseline = ($baseline.FindingIds.Count -eq 0) -and ($findings.Count -gt 0)
$firstBaselineSeeded = $false
$toAlert = if ($AlertOnlyOnNewForwardingFindings) { $newFindings } else { @($findings) }
if ($SuppressAlertsOnFirstBaselineRun -and $isFirstBaseline) {
Write-ExchLog 'Forwarding: first baseline - alerts suppressed (SuppressAlertsOnFirstBaselineRun)'
$firstBaselineSeeded = $true
$toAlert = @()
}
foreach ($f in $toAlert) {
$body = Format-ForwardingFindingMessage -Finding $f
Send-MonitorNotification -Message $body -EmailSubject 'Exchange: external forward' | Out-Null
}
if ($findings.Count -eq 0 -and $NotifyWhenForwardingScanClean) {
$summary = "<b>Exchange: forwarding scan</b>`r`nNo external forward (Inbox / mailbox / transport).`r`nHost: $(ConvertTo-TelegramHtml $env:COMPUTERNAME)"
Send-MonitorNotification -Message $summary -EmailSubject 'Exchange: forward scan OK' | Out-Null
} elseif ($newFindings.Count -eq 0 -and $AlertOnlyOnNewForwardingFindings) {
Write-ExchLog 'Forwarding: no changes (known findings in baseline only)'
}
Save-ForwardingBaseline -FindingIds $allIds
Write-TextFileUtf8Bom -Path $InboxHeartbeatFile -Text (Get-Date -Format 'yyyy-MM-dd HH:mm:ss')
Send-ExchangeInboxScanSummary -TotalFindings $findings.Count -NewFindings $newFindings.Count `
-MailboxCount $mailboxCount -FirstBaselineSeeded $firstBaselineSeeded -ScopeLabel $scopeLabel
Write-ExchLog 'Inbox/Forwarding scan finished'
}
# ============================================
# WATCHDOG
# ============================================
function Get-HeartbeatAgeSeconds {
param([string]$Path)
if (-not (Test-Path -LiteralPath $Path)) { return [double]::PositiveInfinity }
try {
$t = Get-Content -LiteralPath $Path -TotalCount 1 -ErrorAction Stop
if ([string]::IsNullOrWhiteSpace($t)) { return [double]::PositiveInfinity }
$dt = [datetime]::ParseExact($t.Trim(), 'yyyy-MM-dd HH:mm:ss', $null)
return ((Get-Date) - $dt).TotalSeconds
} catch { return [double]::PositiveInfinity }
}
function Invoke-ExchangeWatchdog {
Write-WdLog "Watchdog Exchange-MailSecurity v$ScriptVersion"
$issues = [System.Collections.Generic.List[string]]::new()
$qAge = Get-HeartbeatAgeSeconds -Path $QueuesHeartbeatFile
if ($qAge -gt $QueuesHeartbeatStaleSeconds) {
$issues.Add("Queues: no successful scan > $([int]$qAge) s (threshold $QueuesHeartbeatStaleSeconds s)") | Out-Null
}
$iAge = Get-HeartbeatAgeSeconds -Path $InboxHeartbeatFile
if ($iAge -gt $InboxHeartbeatStaleSeconds) {
$issues.Add("Inbox/Forwarding: no successful scan > $([int]$iAge) s (threshold $InboxHeartbeatStaleSeconds s)") | Out-Null
}
if ($issues.Count -eq 0) {
Write-WdLog 'Watchdog: heartbeat OK'
exit 0
}
$msg = "<b>Exchange Mail Security: watchdog</b>`r`n"
$msg += "Host: $(ConvertTo-TelegramHtml $env:COMPUTERNAME)`r`n"
foreach ($iss in $issues) {
$msg += "- $(ConvertTo-TelegramHtml $iss)`r`n"
}
Send-MonitorNotification -Message $msg -EmailSubject 'Exchange monitor: watchdog' | Out-Null
Write-WdLog "Watchdog: alert sent ($($issues.Count) issue(s))"
exit 1
}
# ============================================
# MAIN
# ============================================
if (-not (Test-RunningElevated)) {
Write-ExchLog 'WARNING: not running elevated - EMS/tasks may fail.'
}
Write-ExchLog "=== Exchange-MailSecurity v$ScriptVersion Mode=$Mode$(if ($WhatIf) { ' WhatIf' }) ==="
Write-ExchangeScanSafetyWarnings
try {
switch ($Mode) {
'Queues' { Invoke-ExchangeQueueScan }
'Inbox' { Invoke-ExchangeInboxAndForwardingScan }
'Watchdog' { Invoke-ExchangeWatchdog }
default { throw "Unknown Mode: $Mode" }
}
} catch {
Write-ExchLog "ERROR: $($_.Exception.Message)"
if ($_.ScriptStackTrace) { Write-ExchLog $_.ScriptStackTrace }
exit 1
}
exit 0
+45
View File
@@ -0,0 +1,45 @@
#Requires -RunAsAdministrator
[CmdletBinding()]
param(
[string]$TaskName = "RDP-Login-Monitor-Deploy",
[string]$DeployScriptPath = "\\dc.contoso.local\NETLOGON\RDP-login-monitor\Deploy-LoginMonitor.ps1",
[ValidateRange(5, 1440)][int]$RepeatMinutes = 60,
[switch]$RunNow
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
if ([string]::IsNullOrWhiteSpace($DeployScriptPath)) {
throw "DeployScriptPath не задан."
}
$psExe = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe"
if (-not (Test-Path -LiteralPath $psExe)) {
throw "Не найден powershell.exe по пути: $psExe"
}
$taskCmd = "`"$psExe`" -NoProfile -ExecutionPolicy Bypass -File `"$DeployScriptPath`""
$schtasksExe = Join-Path $env:SystemRoot "System32\schtasks.exe"
Write-Host "Создаю/обновляю задачу: $TaskName" -ForegroundColor Cyan
Write-Host "Команда: $taskCmd" -ForegroundColor DarkGray
Write-Host "Периодичность: каждые $RepeatMinutes мин." -ForegroundColor DarkGray
& $schtasksExe /Create /F /TN $TaskName /RU SYSTEM /RL HIGHEST /SC MINUTE /MO $RepeatMinutes /TR $taskCmd | Out-Host
if ($LASTEXITCODE -ne 0) {
throw "schtasks /Create вернул код $LASTEXITCODE"
}
Write-Host "Задача создана/обновлена: $TaskName" -ForegroundColor Green
if ($RunNow) {
Write-Host "Запускаю задачу немедленно..." -ForegroundColor Cyan
& $schtasksExe /Run /TN $TaskName | Out-Host
if ($LASTEXITCODE -ne 0) {
throw "schtasks /Run вернул код $LASTEXITCODE"
}
}
Write-Host "Готово." -ForegroundColor Green
+51
View File
@@ -0,0 +1,51 @@
<#
.SYNOPSIS
Register scheduled tasks for domain monitors (Exchange; AD later).
.PARAMETER Target
Exchange - Exchange-MailSecurity.ps1; Ad - reserved.
#>
[CmdletBinding()]
param(
[ValidateSet('Exchange', 'Ad')]
[Parameter(Mandatory = $true)]
[string]$Target
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$InstallRoot = [System.IO.Path]::GetFullPath("$env:ProgramData\RDP-login-monitor")
$PsExe = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe"
function Test-RunningElevated {
$id = [Security.Principal.WindowsIdentity]::GetCurrent()
if ($null -ne $id.User -and $id.User.Value -eq 'S-1-5-18') { return $true }
$p = New-Object Security.Principal.WindowsPrincipal($id)
return $p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
if (-not (Test-RunningElevated)) {
throw 'Run Install-DomainMonitors.ps1 as Administrator.'
}
switch ($Target) {
'Exchange' {
$scriptPath = Join-Path $InstallRoot 'Exchange-MailSecurity.ps1'
if (-not (Test-Path -LiteralPath $scriptPath)) {
throw "Not found: $scriptPath"
}
$notifyPath = Join-Path $InstallRoot 'Notify-Common.ps1'
if (-not (Test-Path -LiteralPath $notifyPath)) {
throw "Not found: $notifyPath"
}
& $PsExe -NoProfile -ExecutionPolicy Bypass -File $scriptPath -InstallTasks
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Write-Host 'Exchange: scheduled tasks registered.'
}
'Ad' {
Write-Host 'AD-SecurityMonitor.ps1 is not implemented yet. Use -Target Exchange.'
exit 1
}
}
exit 0
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Andrey Lutsenko (PapaTramp)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+5424
View File
File diff suppressed because it is too large Load Diff
+214
View File
@@ -0,0 +1,214 @@
<#
.SYNOPSIS
Общие функции уведомлений (Telegram / SMTP) для скриптов RDP-login-monitor.
.DESCRIPTION
Dot-source после определения Write-NotifyLog в вызывающем скрипте.
Ожидает переменные: $NotifyOrder, $TelegramBotToken, $TelegramChatID,
$MailSmtpHost, $MailFrom, $MailTo и др. (см. Login_Monitor.ps1).
#>
if (-not (Get-Command Write-NotifyLog -ErrorAction SilentlyContinue)) {
function Write-NotifyLog {
param([string]$Message)
Write-Host $Message
}
}
function Unprotect-RdpMonitorDpapiB64 {
param([Parameter(Mandatory = $true)][string]$Base64)
Add-Type -AssemblyName System.Security
$bytes = [Convert]::FromBase64String($Base64.Trim())
$plain = [System.Security.Cryptography.ProtectedData]::Unprotect(
$bytes,
$null,
[System.Security.Cryptography.DataProtectionScope]::LocalMachine
)
return [Text.Encoding]::UTF8.GetString($plain)
}
function Initialize-NotifyCredentials {
param(
[string]$TelegramBotTokenProtectedB64 = '',
[string]$TelegramChatIDProtectedB64 = '',
[ref]$TelegramBotToken,
[ref]$TelegramChatID,
[string]$MailSmtpPasswordProtectedB64 = '',
[ref]$MailSmtpPassword
)
if (-not [string]::IsNullOrWhiteSpace($TelegramBotTokenProtectedB64)) {
$TelegramBotToken.Value = Unprotect-RdpMonitorDpapiB64 -Base64 $TelegramBotTokenProtectedB64
}
if (-not [string]::IsNullOrWhiteSpace($TelegramChatIDProtectedB64)) {
$TelegramChatID.Value = Unprotect-RdpMonitorDpapiB64 -Base64 $TelegramChatIDProtectedB64
}
if (-not [string]::IsNullOrWhiteSpace($MailSmtpPasswordProtectedB64)) {
$MailSmtpPassword.Value = Unprotect-RdpMonitorDpapiB64 -Base64 $MailSmtpPasswordProtectedB64
}
}
function Test-NotifyTelegramConfigured {
return (-not [string]::IsNullOrWhiteSpace($TelegramBotToken)) -and
(-not [string]::IsNullOrWhiteSpace($TelegramChatID))
}
function Test-NotifyEmailConfigured {
return (-not [string]::IsNullOrWhiteSpace($MailSmtpHost)) -and
(-not [string]::IsNullOrWhiteSpace($MailFrom)) -and
(-not [string]::IsNullOrWhiteSpace($MailTo))
}
function Get-NotifyOrderChannels {
$configured = [System.Collections.Generic.List[string]]::new()
if (Test-NotifyTelegramConfigured) { $configured.Add('telegram') | Out-Null }
if (Test-NotifyEmailConfigured) { $configured.Add('email') | Out-Null }
if ([string]::IsNullOrWhiteSpace($NotifyOrder)) {
return @($configured)
}
$requested = [System.Collections.Generic.List[string]]::new()
foreach ($part in ($NotifyOrder -split '[,\s;]+')) {
$p = $part.Trim().ToLowerInvariant()
if ([string]::IsNullOrWhiteSpace($p)) { continue }
$channel = switch -Regex ($p) {
'^(tg|telegram)$' { 'telegram' }
'^(mail|email|e-mail)$' { 'email' }
default {
Write-NotifyLog "NotifyOrder: unknown channel '$part'"
$null
}
}
if ($null -eq $channel) { continue }
if ($configured.Contains($channel) -and -not $requested.Contains($channel)) {
$requested.Add($channel) | Out-Null
}
}
return @($requested)
}
function Get-NotifyChainHuman {
$channels = @(Get-NotifyOrderChannels)
if ($channels.Count -eq 0) { return 'none (Telegram and SMTP not configured)' }
$labels = foreach ($ch in $channels) {
switch ($ch) {
'telegram' { 'Telegram' }
'email' { 'Email (SMTP)' }
default { $ch }
}
}
return ($labels -join ' → ')
}
function ConvertTo-TelegramHtml {
param([string]$Text)
if ($null -eq $Text) { return '' }
return [System.Net.WebUtility]::HtmlEncode([string]$Text)
}
function Send-TelegramMessage {
param([string]$Message)
if (-not (Test-NotifyTelegramConfigured)) {
Write-NotifyLog 'Telegram: token or chat_id missing'
return $false
}
$uri = "https://api.telegram.org/bot$TelegramBotToken/sendMessage"
$body = @{
chat_id = $TelegramChatID
text = $Message
parse_mode = "HTML"
}
try {
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
$null = Invoke-RestMethod -Uri $uri -Method Post -Body $body -ErrorAction Stop -TimeoutSec 30
return $true
} catch {
Write-NotifyLog "Telegram send error: $($_.Exception.Message)"
return $false
}
}
function ConvertTo-EmailHtmlBody {
param([string]$TelegramHtmlMessage)
$inner = [string]$TelegramHtmlMessage
if ([string]::IsNullOrEmpty($inner)) { $inner = '' }
$inner = $inner -replace "`r`n", "<br>`r`n"
return @"
<html>
<body style="font-family:Segoe UI,Arial,sans-serif;font-size:14px;line-height:1.4;">
$inner
</body>
</html>
"@
}
function Send-EmailNotification {
param(
[string]$Message,
[string]$Subject = "RDP Login Monitor"
)
if (-not (Test-NotifyEmailConfigured)) {
Write-NotifyLog 'Email: SMTP not configured'
return $false
}
try {
$toList = @($MailTo -split '[,;]' | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
if ($toList.Count -eq 0) { return $false }
$mailParams = @{
To = $toList
From = $MailFrom.Trim()
Subject = $Subject
Body = (ConvertTo-EmailHtmlBody -TelegramHtmlMessage $Message)
BodyAsHtml = $true
SmtpServer = $MailSmtpHost.Trim()
Port = [int]$MailSmtpPort
Encoding = [System.Text.Encoding]::UTF8
ErrorAction = 'Stop'
}
if ($MailSmtpSsl -or $MailSmtpStartTls) { $mailParams['UseSsl'] = $true }
if (-not [string]::IsNullOrWhiteSpace($MailSmtpUser)) {
$securePass = if ([string]::IsNullOrWhiteSpace($MailSmtpPassword)) {
New-Object System.Security.SecureString
} else {
ConvertTo-SecureString $MailSmtpPassword -AsPlainText -Force
}
$mailParams['Credential'] = New-Object System.Management.Automation.PSCredential($MailSmtpUser.Trim(), $securePass)
}
Send-MailMessage @mailParams
return $true
} catch {
Write-NotifyLog "Email send error: $($_.Exception.Message)"
return $false
}
}
function Send-MonitorNotification {
param(
[string]$Message,
[string]$EmailSubject = "RDP Login Monitor"
)
$channels = @(Get-NotifyOrderChannels)
if ($channels.Count -eq 0) {
Write-NotifyLog 'Notification skipped: no channels configured'
return $false
}
$anyOk = $false
foreach ($ch in $channels) {
$ok = switch ($ch) {
'telegram' { Send-TelegramMessage -Message $Message }
'email' { Send-EmailNotification -Message $Message -Subject $EmailSubject }
default { $false }
}
if ($ok) { $anyOk = $true }
}
return $anyOk
}
+62
View File
@@ -0,0 +1,62 @@
# RDP Login Monitor
**Версия:** `2.1.14-SAC` (`$ScriptVersion` + `version.txt`)
PowerShell-мониторинг Windows: RDP/RDS, RD Gateway, WinRM, admin share, блокировки УЗ, heartbeat, отчёты.
## Установка (домен)
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File "\\<DC>\NETLOGON\RDP-login-monitor\Deploy-LoginMonitor.ps1"
```
Каталог: `C:\ProgramData\RDP-login-monitor\` · настройки: `login_monitor.settings.ps1` (не перезаписываются при деплое).
## Обновление через SAC (WinRM)
С карточки хоста в 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.papatramp.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.papatramp.ru/PapaTramp/security-alert-center/src/branch/main/docs/agent-integration.md). Poll команд `qwinsta`/`logoff` (≥ 2.1.0-SAC).
## Ключевые файлы
| Файл | Назначение |
|------|------------|
| `Login_Monitor.ps1` | Основной цикл |
| `Sac-Client.ps1` | Отправка в SAC |
| `Deploy-LoginMonitor.ps1` | NETLOGON, задачи, WinRM self-heal |
| `login_monitor.settings.ps1` | Секреты и параметры (локально) |
## Быстрые проверки
```powershell
Get-Content "C:\ProgramData\RDP-login-monitor\Logs\login_monitor.log" -Tail 60
Login_Monitor.ps1 -CheckSac
```
## Документация
- [Docs/README.md](Docs/README.md) — развёртывание, Exchange
- [security-alert-center](https://git.papatramp.ru/PapaTramp/security-alert-center) — сервер, RDG flap, qwinsta через SAC UI/Seaca
## English
See repository docs; agent contract is in SAC `docs/agent-integration.md`.
+150
View File
@@ -0,0 +1,150 @@
# RDP Login Monitor
**Version:** `2.1.14-SAC` (`$ScriptVersion` + `version.txt`)
PowerShell toolkit for monitoring Windows logons with Telegram and/or Email (SMTP) notifications.
## Recommended layout
- Installation root: **`C:\ProgramData\RDP-login-monitor\`**.
- Main script: **`Login_Monitor.ps1`** — Security log **`4624`/`4625`** (behavior depends on OS type: workstation vs server/domain controller), **aggregated `4625` burst alerts** (two tiers: IP+user and IP-only), optional **Remote Connection Manager `1149`** when the log is available (often useful for RDP-enabled workstations), **RD Gateway** events **`302`/`303`** when the gateway role/log is present, on the **DC where the monitor runs** (hostname matches **`$LockoutMonitorDomainController`**) — **`4740`** (account lockout + IPs from IIS ActiveSync), **daily report** (active sessions via `quser`), **heartbeat**, **log rotation**, alerts via Telegram and/or Email.
- Scheduled tasks: run **`Login_Monitor.ps1 -InstallTasks`** to register:
- `RDP-Login-Monitor` (main monitor),
- `RDP-Login-Monitor-Watchdog` (process health check every 5 minutes).
- Domain delivery and upgrades: **`Deploy-LoginMonitor.ps1`** + **`version.txt`** on a share such as `NETLOGON`. After a successful deploy, the startup notification may include an update note (file **`deploy_last_update.txt`** next to logs).
- Deployment docs: **[Docs/README.md](Docs/README.md)** (RDP monitor, Exchange, NETLOGON).
- **`Encrypt-DpapiForRdpMonitor.ps1`** — optional helper to prepare DPAPI-protected Base64 for the bot token / chat id and SMTP password (`$MailSmtpPasswordProtectedB64` in the settings file).
- **RDP monitor local settings:** **`login_monitor.settings.ps1`** in the install directory (template **`login_monitor.settings.example.ps1`**). When **`Login_Monitor.ps1`** is auto-updated from the share, the settings file is **not overwritten** (same pattern as **`exchange_monitor.settings.ps1`** for Exchange).
## Notable behavior
- **`.ps1` encoding**: `.editorconfig` and `.gitattributes` encourage **`*.ps1`** as **UTF-8 with BOM** and **CRLF**, reducing mojibake and PowerShell parse issues.
- **Log encoding**: `login_monitor.log` / `watchdog.log` are written as **UTF-8 with BOM** (BOM is applied to existing files if missing) so viewers like **FAR Manager** do not mis-detect encoding.
- **`auditpol` on Russian Windows**: auditing checks use the **`Вход/выход`** category and **`Вход в систему` / `Выход из системы`** subcategories (expect **`Успех и сбой`** or **`Успех и отказ`**, depending on OS build), avoiding errors such as `0x00000057` when English names like `Logon` are absent on a localized OS.
- **Stability**: `auditpol` is invoked via full path `%SystemRoot%\System32\auditpol.exe` (no PATH dependency); stdout and stderr are merged via `ProcessStartInfo`.
- **`4625` burst alerts**: when `$FailedLogonRateLimitEnabled` is true — tier 1: **5** failures in **60** s per **IP+user**; tier 2: **12** in **60** s per **IP** (spray). Below thresholds, individual `4625` alerts are sent; during a burst, aggregated alerts replace per-event noise. No automatic IP blocking. Tune at the top of `Login_Monitor.ps1`.
- **Exchange Mail Security** (`Exchange-MailSecurity.ps1`): Exchange server only — queues, external forwarding, watchdog. See **[Docs/exchange-mail-security.md](Docs/exchange-mail-security.md)**.
## 1) Preparation
1. Create the install folder:
- `C:\ProgramData\RDP-login-monitor\`
2. Copy at least:
- `Login_Monitor.ps1`
- `login_monitor.settings.example.ps1` → rename to **`login_monitor.settings.ps1`** and configure (see step 3)
- (for domain rollout on a share) `Deploy-LoginMonitor.ps1`, `version.txt`, and `login_monitor.settings.example.ps1`
3. Configure **`C:\ProgramData\RDP-login-monitor\login_monitor.settings.ps1`** (do not put secrets in `Login_Monitor.ps1` — they are overwritten on deploy):
- **Telegram:** `$TelegramBotToken` / `$TelegramChatID` or `...ProtectedB64`
- **Email (SMTP):** `$MailSmtpHost`, `$MailFrom`, `$MailTo`, `$MailSmtpPort` (default 587), optionally `$MailSmtpUser` / `$MailSmtpPassword` (or `$MailSmtpPasswordProtectedB64` via DPAPI), `$MailSmtpStartTls` / `$MailSmtpSsl`
- **Order:** `$NotifyOrder` — empty = auto (Telegram → Email, configured channels only); otherwise `telegram,email`, `email`, etc. (`tg`, `mail` are accepted)
4. Run elevated (Security log access and task registration).
5. Logs and auxiliary files:
- `C:\ProgramData\RDP-login-monitor\Logs\`
6. (Optional) Suppress some Security alerts via **`ignore.lst`** — see **section 7** below.
7. (Optional) AD account lockout monitoring on a DC — in **`login_monitor.settings.ps1`**: **`$LockoutMonitorDomainController`** (short hostname of the machine **where the monitor is installed and running**), **`$NetBiosDomainName`**, **`$ExchangeIisLogPath`**, **`$ExchangeIisLogMinutesBeforeLockout`** (default 30), **`$ExchangeIisLogTailLines`** (default 5000), **`$ExchangeServerHostForIisExclude`**. Alerts include the user from event **4740** and client IPs from IIS within the time window before lockout. In **`ignore.lst`** use prefix **`4740:`** or **`all:`** — see **`ignore.lst.example`**.
8. Heartbeat: if **`Logs\last_heartbeat.txt`** is not updated for longer than **`$HeartbeatStaleAlertMultiplier` × `$HeartbeatInterval`** (default 2×1 h) — alert via Telegram/Email.
## 2) Manual run
Use this to validate startup/logic without registering scheduled tasks.
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\RDP-login-monitor\Login_Monitor.ps1"
```
The monitor keeps running in the session until you stop it (for example `Ctrl+C`).
## 3) Task Scheduler
You do not need to create tasks manually in the GUI.
Run:
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\RDP-login-monitor\Login_Monitor.ps1" -InstallTasks
```
The script registers `RDP-Login-Monitor` and `RDP-Login-Monitor-Watchdog`, then triggers an immediate first run.
For domain deployment from a share you do not configure the scheduler on clients by hand — use `Deploy-LoginMonitor.ps1` (see [Docs/deploy-rdp-login-monitor.md](Docs/deploy-rdp-login-monitor.md)).
## 4) Post-install checks
- Logs:
- `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` updates on **`$HeartbeatInterval`** (hourly by default).
- Daily report: after the first daily window (default **09:00**, controlled by **`$DailyReportHour`** / **`$DailyReportMinute`** in `Login_Monitor.ps1`), a `quser` summary is sent (Telegram/Email); last run marker: `Logs\last_daily_report.txt`.
- Stale heartbeat: if **`last_heartbeat.txt`** is stale beyond **`$HeartbeatStaleAlertMultiplier` × `$HeartbeatInterval`** — alert via Telegram/Email (see preparation step 8).
- At startup (Telegram/Email): **notification channels** line (actual delivery order) plus RDS/4740 mode per configuration.
- Startup message (Telegram): with **RD Session Host** (or broader RDS session components, not gateway-only) you get the RDS/RDP session-host line; when the **RD Gateway** log is available you get a separate line about connections to **internal targets** through the gateway (302/303). A gateway-only node does not duplicate the “session host” wording.
## 5) Automatic restart on failure
`-Watchdog` inside `Login_Monitor.ps1`:
- looks for `powershell.exe` / `pwsh.exe` whose command line references `Login_Monitor.ps1`;
- if the main monitor is missing, starts it;
- if the monitor is already running, does not spawn a second instance.
## 6) Extra parameters and other repo files
- **`-SkipScheduledTaskMaintenance`**: during normal monitor startup, skip verification/recreation of scheduled tasks (if you manage tasks only via **`-InstallTasks`** or manually).
- **`Install-DeployScheduledTask.ps1`** — helper to run **`Deploy-LoginMonitor.ps1`** from a share on a schedule (see **[DEPLOY.md](DEPLOY.md)**).
- Watchdog is built into **`Login_Monitor.ps1`** (`-Watchdog`); scheduled tasks **`RDP-Login-Monitor`** / **`RDP-Login-Monitor-Watchdog`** are registered by **`-InstallTasks`**.
- **`ignore.lst.example`** in the repo is a template for **`ignore.lst`** to suppress selected Security notifications (see section 7).
- **`login_monitor.settings.example.ps1`** — template for **`login_monitor.settings.ps1`** (Telegram, SMTP, 4740, local IP exclusions). Deploy may create `login_monitor.settings.ps1` from the example on first install.
## 7) Suppressing Security alerts: `ignore.lst`
Place a file **`C:\ProgramData\RDP-login-monitor\ignore.lst`** next to **`Login_Monitor.ps1`**. By default rules apply to **`4624`/`4625`**; prefix **`4740:`** (or **`lockout:`**) — account lockouts only; **`all:`** — logons and **4740**. For **4740**, rule type **`ip:`** is matched against IPs from IIS ActiveSync. Built-in script exclusions still apply to all event types except **4740** (lockouts use **`ignore.lst`** and built-in user checks only).
**RD Gateway (`302`/`303`)**, **RCM `1149`**, the daily report, and heartbeat **are not controlled** by this file.
### How the file is loaded
- The file is read during event processing; entries are **cached in memory**. If **`LastWriteTimeUtc`** changes (save after edit), the list is **reloaded automatically** — no monitor restart required.
- Encoding: **UTF-8** (`Get-Content -Encoding UTF8`). A UTF-8 BOM at the start of a line is stripped when parsing.
- Blank lines are skipped. Lines starting with **`#`** or **`;`** are comments.
- If the line contains **`:`**, the **first** colon splits the line: the left part (trimmed) selects the rule kind, the right part is the value. If the right part is empty, the line is ignored.
- If there is **no** colon, the whole trimmed line is one “match-any” value (see below).
### Scope prefix (at the start of the line, before the rule type)
| Prefix | Events |
| --- | --- |
| *(none)* | **4624**, **4625** |
| `4740:`, `lockout:` | **4740** |
| `all:`, `*:` | **4624**, **4625**, **4740** |
Example: `4740:user:svc_sync` — do not alert on lockout for that account.
### Rule kinds (left part before the first `:` after the scope prefix)
| Left part (case-insensitive regex fragments) | Event field |
| --- | --- |
| `рабоч`, `workstation`, `wks` | workstation name (**WorkstationName** and similar XML fields) |
| `польз`, `username`, `subject`, `account`, `target user`, whole word `user` | user name (**TargetUserName**, etc.) |
| `ip`, `ip адрес`, `ipaddress`, `адрес ip` | source IP (**IpAddress**, etc.) when present |
If the left part matches none of the above but a colon exists, the parser behaves like the Telegram line splitter: kind is **Any**, value is **only the right part** (the label on the left is discarded).
### “Any” matching (no colon, or unknown label before `:`)
Evaluated in order:
1. If the value looks like **IPv4**, it is compared to the event source IP (exact match).
2. If the value contains **`\`**, it is matched against the **account**: full `DOMAIN\user` equality **or** SAM after the last `\` (e.g. rule `IVANOV` matches `DOMAIN\IVANOV`).
3. Otherwise: exact match against **workstation name** first, then the same account rules as in step 2.
Explicit **User** / **Workstation** / **Ip** kinds only compare their respective field (user rules use the same full-name and SAM behavior as step 2).
### Examples and deployment
- See **`ignore.lst.example`** in the repo; copy it to the server as **`ignore.lst`** and edit.
- **`Deploy-LoginMonitor.ps1`** does **not** copy or overwrite **`ignore.lst`** or **`login_monitor.settings.ps1`**; if settings are missing, Deploy creates them once from **`login_monitor.settings.example.ps1`** on the share.
## Keywords (for discovery)
`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`
+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())
}
+29
View File
@@ -0,0 +1,29 @@
<#
.SYNOPSIS
Graceful restart RDP Login Monitor без Stop-Process.
.DESCRIPTION
settings — перечитать login_monitor.settings.ps1 в том же процессе PowerShell.
recycle — корректно завершить монитор и запустить новый процесс (после обновления Login_Monitor.ps1).
.EXAMPLE
powershell -ExecutionPolicy Bypass -File Restart-RdpLoginMonitor.ps1
powershell -ExecutionPolicy Bypass -File Restart-RdpLoginMonitor.ps1 -Recycle
#>
[CmdletBinding()]
param(
[switch]$Recycle
)
$ErrorActionPreference = 'Stop'
$installRoot = [System.IO.Path]::GetFullPath("$env:ProgramData\RDP-login-monitor")
$monitorScript = Join-Path $installRoot 'Login_Monitor.ps1'
if (-not (Test-Path -LiteralPath $monitorScript)) {
Write-Error "Не найден: $monitorScript"
exit 1
}
$args = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $monitorScript, '-RequestRestart')
if ($Recycle) { $args += '-Recycle' }
& "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" @args
exit $LASTEXITCODE
+1270
View File
File diff suppressed because it is too large Load Diff
+54
View File
@@ -0,0 +1,54 @@
<#
.SYNOPSIS
Пример локальных настроек Exchange-MailSecurity.ps1
.DESCRIPTION
Скопируйте в C:\ProgramData\RDP-login-monitor\exchange_monitor.settings.ps1
и задайте секреты / whitelist. Файл не деплоится с шары автоматически.
#>
# Telegram (или DPAPI Base64 с Encrypt-DpapiForRdpMonitor.ps1)
$TelegramBotToken = ''
$TelegramChatID = ''
# $TelegramBotTokenProtectedB64 = ''
# $TelegramChatIDProtectedB64 = ''
# SMTP (опционально)
# $MailSmtpHost = 'smtp.example.com'
# $MailFrom = 'monitor@example.com'
# $MailTo = 'admin@example.com'
# $NotifyOrder = 'telegram,email'
# Внешние домены-партнёры (не считать угрозой)
$ExternalDomainWhitelist = @(
# 'partner-bank.ru'
)
# Очереди
$QueueMessageCountThreshold = 150
# --- Пилот VIP (рекомендуется для первого запуска) ---
# $VipMailboxesOnly = $true
# $VipMailboxes = @(
# 'director@kalinamall.ru',
# 'cfo@kalinamall.ru'
# )
# $VipMailboxPatterns = @(
# '*@kalinamall.ru' # опционально: все ящики домена из Get-Mailbox
# )
# Первый ночной скан: не слать сотни алертов по уже существующим пересылкам
# $SuppressAlertsOnFirstBaselineRun = $true # по умолчанию в скрипте уже $true
# Отключённые Inbox rules с внешней пересылкой (важность «Средняя»)
# $ScanDisabledInboxRulesWithExternalForward = $true
# Сводка в TG/Email после каждого скана
# $SendInboxScanSummary = $true
# Удалённый EMS (если скрипт не на Exchange)
# $ExchangeServerFqdn = 'fifth.kalinamall.ru'
# Не сканировать Inbox rules (битое хранилище правил / Watson на Get-InboxRule)
# $SkipInboxScanMailboxes = @(
# 'k.selezneva@kalinamall.ru'
# )
+44
View File
@@ -0,0 +1,44 @@
# Файл ignore.lst (UTF-8): положите рядом с Login_Monitor.ps1 в каталоге установки:
# C:\ProgramData\RDP-login-monitor\ignore.lst
#
# Каждая непустая строка — одно правило. Строки с # или ; в начале — комментарии.
#
# Область действия (префикс в начале строки, необязателен):
# (по умолчанию) — только Security 4624/4625
# 4740: — только блокировка учётной записи (4740); для IP — любой IP из IIS
# shadow: — RDS Shadow Control (RCM 20506/20507/20510)
# winrm: / pssession: — WinRM inbound / Enter-PSSession (Operational 91)
# smb: / 5140: / adminshare: — доступ к админ-шару C$/ADMIN$ (Security 5140)
# all: — 4624/4625, 4740, shadow, winrm, 5140
#
# Форматы правила (после префикса области):
# user:domain\user
# user:user
# workstation:IVANOV (не для 4740)
# ip:111.222.333.444
#
# Строка без префикса типа:
# IVANOV — рабочая станция / пользователь / IP (IPv4)
# domain\user — пользователь
# --- только входы 4624/4625 ---
# user:domain\service_account
# ip:192.168.1.100
# --- только блокировки 4740 ---
# 4740:user:test.user
# 4740:ip:203.0.113.50
# --- только WinRM / Enter-PSSession inbound ---
# winrm:user:DOMAIN\jump-admin
# winrm:ip:192.168.160.50
# --- только admin share 5140 (C$, ADMIN$) ---
# smb:user:DOMAIN\backup-svc
# 5140:ip:192.168.160.50
# --- только RDS Shadow Control ---
# shadow:user:DOMAIN\helpdesk
# --- все перечисленные события ---
# all:user:domain\noise_account
+93
View File
@@ -0,0 +1,93 @@
<#
.SYNOPSIS
Локальные настройки Login_Monitor.ps1
.DESCRIPTION
Скопируйте в C:\ProgramData\RDP-login-monitor\login_monitor.settings.ps1
и при необходимости отредактируйте. Deploy-LoginMonitor.ps1 не перезаписывает settings,
если SAC уже настроен (UseSAC не off и задан SacApiKey). При первой установке или апгрейде
с версии без SAC (нет Sac-Client.ps1 / пустой ключ) example копируется поверх с резервной .bak.
#>
# --- Telegram (или DPAPI Base64 через Encrypt-DpapiForRdpMonitor.ps1) ---
$TelegramBotToken = '8239219522:AAEyOZX3cwNfgGOMDkf-mgjTIuoaOh5gF7I'
$TelegramChatID = '2843230'
# $TelegramBotTokenProtectedB64 = ''
# $TelegramChatIDProtectedB64 = ''
# --- Email (опционально) ---
$NotifyOrder = 'tg'
# $MailSmtpHost = 'smtp.example.com'
# $MailSmtpPort = 587
# $MailSmtpUser = ''
# $MailSmtpPassword = ''
# $MailFrom = 'monitor@example.com'
# $MailTo = 'admin@example.com'
# $MailSmtpStartTls = $true
# $MailSmtpSsl = $false
# $MailSmtpPasswordProtectedB64 = ''
# --- Подпись сервера в Telegram и SAC (host.display_name); пусто = $env:COMPUTERNAME ---
# $ServerDisplayName = 'UNMS Kalina'
# --- Явный IPv4 хоста для SAC (опционально; иначе автоопределение) ---
# $ServerIPv4 = '192.168.160.57'
# --- Security Alert Center (SAC) ---
# off | exclusive | dual | fallback — см. security-alert-center/docs/agent-integration.md
$UseSAC = 'fallback'
$SacUrl = 'https://sac.kalinamall.ru'
$SacApiKey = 'sac_UkOsAT3UWiQS54KK5OJPBDCSucysQDrKFju28wmYiz8'
$SacSpoolDir = 'C:\ProgramData\RDP-login-monitor\sac-spool'
$SacTimeoutSec = 45
$SacSpoolFlushMaxFiles = 50
$SacSpoolMaxAgeHours = 72
# $SacTlsSkipVerify = $false
# $SacFallbackFailures = 5
# $false = не слать report.daily.rdp с агента (суточный отчёт только из SAC)
# В settings.ps1 используйте 1/0 или $true/$false — не пишите голое false без $
$DailyReportEnabled = 1
# --- Heartbeat SAC (agent.heartbeat): интервал в секундах; 14400 = 4 ч ---
$HeartbeatInterval = 14400
# Оповещение, если last_heartbeat.txt не обновлялся > множитель × интервал (2 × 4 ч = 8 ч)
$HeartbeatStaleAlertMultiplier = 2
# Poll SAC на команды qwinsta/logoff (сек); см. security-alert-center/docs/agent-control-plane.md
# $SacCommandPollIntervalSec = 60
# Окно (мин): LastBootUpTime + System 41/1074/6005/6008/6009 → «старт после перезагрузки ОС»
$StartupRebootDetectMinutes = 5
# --- Инвентаризация железа/ПО для SAC (agent.inventory, раз в 12 ч) ---
$GetInventory = $true
# --- RDS Shadow Control + WinRM inbound (Enter-PSSession), severity warning ---
# $EnableRcm1149Monitoring = 1 # RCM Operational 1149 (RDP auth; workstation + RDS server)
# $EnableRcmShadowControlMonitoring = 1 # RCM Operational 20506/20507/20510
# $EnableWinRmInboundMonitoring = 1 # WinRM Operational 91 (+ correlate Security 4624)
# $EnableAdminShareMonitoring = 1 # Security 5140 C$/ADMIN$ (audit File Share)
# $WinRmIgnoreLocalSource = 1 # ::1, 127.0.0.1, fe80 (шум Exchange/локальный WinRM)
# $WinRmIgnoreMachineAccounts = 1 # учётки, оканчивающиеся на $
# $WinRmExchangeStrictMode = 1 # Exchange: user в Event 91 обязателен; 4624 только LogonProcess WinRM
# HealthMailbox* уже в ExcludedUserPatterns скрипта
# Проверка: powershell -File Login_Monitor.ps1 -CheckSac
# --- Узкое исключение шумовых сетевых логонов (LogonType=3, Advapi) ---
$IgnoreAdvapiNetworkLogonSourceIps = @(
'192.168.160.57'
)
# --- Exchange noise filter: 4624 + LogonType=3 + IP='-' (часто Outlook/почтовые клиенты) ---
# Включайте на почтовом сервере, если нужен только полезный интерактивный сигнал.
${Ignore4624-LT3-EmptyIP-Event} = $false
# --- Ротация login_monitor.log и хранение бэкапов (Logs\Backup\LoginLog_*.bak) ---
# $LogRotationHour = 0
# $LogRotationMinute = 0
$MaxBackupDays = 31
# --- Блокировка учётной записи AD (4740) + IP из IIS ActiveSync ---
# Мониторинг включается только на КД с именем $LockoutMonitorDomainController.
$LockoutMonitorDomainController = 'K6A-DC3'
$NetBiosDomainName = 'B26'
$ExchangeIisLogPath = '\\fifth.kalinamall.ru\c$\inetpub\logs\LogFiles\W3SVC1'
$ExchangeServerHostForIisExclude = ''
$ExchangeIisLogTailLines = 5000
$ExchangeIisLogMinutesBeforeLockout = 30
+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://git.papatramp.ru/PapaTramp/([^)/''"\s]+)/blob/main/'; To = "$Base/`${1}$BlobSuffix/" }
@{ From = 'https://git.papatramp.ru/PapaTramp/([^)/''"\s]+)/src/branch/main/'; To = "$Base/`${1}$BlobSuffix/" }
@{ From = 'https://git.papatramp.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/PapaTramp/([^)/''"\s]+)/src/branch/main/'; To = "$Base/`${1}$BlobSuffix/" }
@{ From = 'https://git.papatramp.ru/PapaTramp/'; To = "$Base/" }
@{ From = 'https://git.papatramp.ru/PapaTramp/'; To = "$Base/" }
@{ From = 'https://git.papatramp.ru/PapaTramp/'; To = "$Base/" }
@{ From = 'https://git.papatramp.ru/PapaTramp/'; To = "$Base/" }
@{ From = 'git.papatramp.ru/PapaTramp/'; To = "$BaseHost/" }
@{ From = 'git.papatramp.ru/PapaTramp/'; To = "$BaseHost/" }
@{ From = 'git.papatramp.ru/PapaTramp/'; To = "$BaseHost/" }
@{ From = 'git.papatramp.ru/PapaTramp/'; 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://git.papatramp.ru/PapaTramp/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://git.papatramp.ru/PapaTramp/$1/src/branch/main/'
$md = $md -replace 'https://git\.papatramp\.ru/PapaTramp/([^)/\s]+)/src/branch/main/', 'https://git.papatramp.ru/PapaTramp/$1/src/branch/main/'
$md = $md -replace 'https://git\.kalinamall\.ru/PapaTramp/', 'https://git.papatramp.ru/PapaTramp/'
$md = $md -replace 'https://git\.papatramp\.ru/PapaTramp/', 'https://git.papatramp.ru/PapaTramp/'
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
+51
View File
@@ -0,0 +1,51 @@
<#
.SYNOPSIS
Просмотр недавних 4624 с полями для диагностики RDP-login-monitor.
.EXAMPLE
.\Show-Rdp4624Recent.ps1
.\Show-Rdp4624Recent.ps1 -Minutes 30 -User jdoe
#>
[CmdletBinding()]
param(
[int]$Minutes = 15,
[string]$User = '',
[int]$MaxEvents = 50
)
$start = (Get-Date).AddMinutes(-$Minutes)
Write-Host "Security 4624 since $($start.ToString('yyyy-MM-dd HH:mm:ss')) (local time)" -ForegroundColor Cyan
$events = @(Get-WinEvent -FilterHashtable @{
LogName = 'Security'
Id = 4624
StartTime = $start
} -MaxEvents $MaxEvents -ErrorAction SilentlyContinue)
if ($events.Count -eq 0) {
Write-Host 'No 4624 events in window.'
exit 0
}
function Get-EvProp($Event, [string]$Name) {
$xml = [xml]$Event.ToXml()
$n = $xml.Event.EventData.Data | Where-Object { $_.Name -eq $Name } | Select-Object -First 1
if ($null -eq $n) { return '-' }
return [string]$n.'#text'
}
$rows = foreach ($ev in $events) {
$u = Get-EvProp $ev 'TargetUserName'
if ($User -and $u -notlike "*$User*") { continue }
[pscustomobject]@{
TimeCreated = $ev.TimeCreated.ToString('yyyy-MM-dd HH:mm:ss')
RecordId = $ev.RecordId
User = $u
LogonType = Get-EvProp $ev 'LogonType'
IpAddress = Get-EvProp $ev 'IpAddress'
Workstation = Get-EvProp $ev 'WorkstationName'
Process = Get-EvProp $ev 'LogonProcessName'
}
}
$rows | Format-Table -AutoSize
Write-Host "`nTip: monitor log — Select-String -Path 'C:\ProgramData\RDP-login-monitor\Logs\*.log' -Pattern 'Notify:|Skip 4624|Notify dedup'"
+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).'
+8
View File
@@ -0,0 +1,8 @@
param([string]$Path = (Join-Path $PSScriptRoot '..\Login_Monitor.ps1'))
$errs = $null
[void][System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path $Path), [ref]$null, [ref]$errs)
if ($errs) {
$errs | ForEach-Object { $_.ToString() }
exit 1
}
Write-Output 'OK'
+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
}
}
+240
View File
@@ -0,0 +1,240 @@
<#
.SYNOPSIS
Obnovlyaet klon RDP-login-monitor s upstream git i kopiruet dist na NETLOGON.
.DESCRIPTION
Dlya servera publikatsii (napr. DC3). Po umolchaniyu GitHub (github.com/PTah).
Na zakrytom zerkale ukazhite -GitUrl URL vashego Gitea.
Posle fetch: vsegda reset --hard na kalinamall/main (bez merge), zatem clean -fd.
Kopiruyutsya: polnyj spisok v Docs/deploy-netlogon-publish.md.
.EXAMPLE
powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\soft\update-rdp-monitor.ps1
.EXAMPLE
powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\soft\update-rdp-monitor.ps1 -WhatIf
#>
[CmdletBinding(SupportsShouldProcess = $true)]
param(
[string]$RepoPath = 'C:\Soft\Git\RDP-login-monitor',
[string]$NetlogonDest = '\\b26\NETLOGON\RDP-login-monitor',
[string]$GitUrl = 'https://git.papatramp.ru/PapaTramp/RDP-login-monitor.git',
[string]$GitBranch = 'main',
[string]$LogFile = 'C:\soft\Logs\update-rdp-monitor.log',
[switch]$SkipGitClean
)
$ErrorActionPreference = 'Stop'
$DistFiles = @(
'Login_Monitor.ps1',
'Sac-Client.ps1',
'RdpMonitor-TaskQuery.ps1',
'version.txt',
'Deploy-LoginMonitor.ps1',
'Restart-RdpLoginMonitor.ps1',
'Exchange-MailSecurity.ps1',
'Notify-Common.ps1',
'Install-DomainMonitors.ps1',
'Deploy-DomainMonitors.ps1',
'exchange_monitor.settings.example.ps1',
'Diagnose-RdpLoginMonitor.ps1',
'login_monitor.settings.example.ps1'
)
function Write-UpdateLog {
param([string]$Message)
$line = '[{0}] {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message
Write-Host $line
$dir = Split-Path -Parent $LogFile
if (-not (Test-Path -LiteralPath $dir)) {
New-Item -ItemType Directory -Path $dir -Force | Out-Null
}
$utf8Bom = New-Object System.Text.UTF8Encoding $true
[System.IO.File]::AppendAllText($LogFile, $line + [Environment]::NewLine, $utf8Bom)
}
function Invoke-GitCommand {
param(
[Parameter(Mandatory = $true)][string[]]$Arguments,
[string]$WorkingDirectory = $RepoPath
)
$prevEap = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try {
Push-Location -LiteralPath $WorkingDirectory
# stderr git (progress) ne dolzhen lomat skript v PowerShell 5.1
$raw = & git @Arguments 2>&1
$code = $LASTEXITCODE
} finally {
Pop-Location
$ErrorActionPreference = $prevEap
}
$lines = @()
foreach ($item in @($raw)) {
if ($null -eq $item) { continue }
if ($item -is [System.Management.Automation.ErrorRecord]) {
$lines += $item.ToString()
} else {
$lines += [string]$item
}
}
foreach ($line in $lines) {
if ($line.Length -gt 0) {
Write-UpdateLog "git: $line"
}
}
if ($code -ne 0) {
throw ("git {0} failed (exit {1})" -f ($Arguments -join ' '), $code)
}
return $lines
}
function Get-GitShortHead {
$lines = Invoke-GitCommand -Arguments @('rev-parse', '--short', 'HEAD')
foreach ($line in $lines) {
$t = $line.Trim()
if ($t -match '^[0-9a-f]{7,40}$') {
return $t
}
}
return ($lines -join ' ').Trim()
}
function Ensure-GitAvailable {
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
throw 'git.exe not found in PATH. Install Git for Windows.'
}
}
function Ensure-GitRepository {
$gitDir = Join-Path $RepoPath '.git'
if (-not (Test-Path -LiteralPath $gitDir)) {
throw "Not a git repo (no .git): $RepoPath. Clone first: git clone $GitUrl `"$RepoPath`""
}
}
function Get-ConfiguredGitRemoteName {
$names = @(Invoke-GitCommand -Arguments @('remote') | ForEach-Object { "$_".Trim() } | Where-Object { $_ })
if ($names.Count -eq 0) { return $null }
if ('kalinamall' -in $names) { return 'kalinamall' }
foreach ($n in $names) {
$url = (& git -C $RepoPath remote get-url $n 2>$null)
if ($url -match 'git\.kalinamall\.ru') { return $n }
}
if ('origin' -in $names) { return 'origin' }
return $names[0]
}
function Ensure-GitKalinamallRemote {
$name = Get-ConfiguredGitRemoteName
if ($null -ne $name) {
$url = (& git -C $RepoPath remote get-url $name 2>$null)
if ($url -match 'git\.kalinamall\.ru') {
Write-UpdateLog "Using remote: $name ($url)"
return $name
}
Write-UpdateLog "Remote $name is not kalinamall ($url); adding kalinamall -> $GitUrl"
} else {
Write-UpdateLog "No remotes; adding kalinamall -> $GitUrl"
}
if ('kalinamall' -in @(& git -C $RepoPath remote 2>$null)) {
Invoke-GitCommand -Arguments @('remote', 'set-url', 'kalinamall', $GitUrl)
return 'kalinamall'
}
Invoke-GitCommand -Arguments @('remote', 'add', 'kalinamall', $GitUrl)
return 'kalinamall'
}
function Update-Repository {
Ensure-GitRepository
$remote = Ensure-GitKalinamallRemote
Invoke-GitCommand -Arguments @('fetch', '--prune', $remote, $GitBranch)
$upstream = "${remote}/${GitBranch}"
$dirty = @(Invoke-GitCommand -Arguments @('status', '--porcelain') | Where-Object { "$_".Trim().Length -gt 0 })
if ($dirty.Count -gt 0) {
Write-UpdateLog "WARN: discarding $($dirty.Count) local change(s) on publish clone (reset --hard + clean)"
foreach ($d in $dirty) {
Write-UpdateLog " $d"
}
}
$mergeHead = Join-Path $RepoPath '.git\MERGE_HEAD'
if (Test-Path -LiteralPath $mergeHead) {
Write-UpdateLog 'WARN: incomplete merge — aborting before sync'
Invoke-GitCommand -Arguments @('merge', '--abort')
}
Invoke-GitCommand -Arguments @('reset', '--hard', $upstream)
if (-not $SkipGitClean) {
Invoke-GitCommand -Arguments @('clean', '-fd')
}
$remoteHead = (Invoke-GitCommand -Arguments @('rev-parse', '--short', $upstream) | Where-Object { $_ -match '^[0-9a-f]{7,40}$' } | Select-Object -First 1)
$head = Get-GitShortHead
Write-UpdateLog "HEAD: $head (target $upstream = $remoteHead)"
}
function Copy-FileToNetlogon {
param(
[Parameter(Mandatory = $true)][string]$SourcePath,
[Parameter(Mandatory = $true)][string]$DestPath
)
if ($SourcePath -like '*.ps1') {
$raw = [System.IO.File]::ReadAllBytes($SourcePath)
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
$text = $utf8NoBom.GetString($raw)
if ($text.Length -gt 0 -and [int][char]$text[0] -eq 0xFEFF) {
$text = $text.Substring(1)
}
$utf8Bom = New-Object System.Text.UTF8Encoding $true
[System.IO.File]::WriteAllText($DestPath, $text, $utf8Bom)
return
}
Copy-Item -LiteralPath $SourcePath -Destination $DestPath -Force
}
function Publish-DistributionFiles {
if (-not (Test-Path -LiteralPath $NetlogonDest)) {
if ($PSCmdlet.ShouldProcess($NetlogonDest, 'Create directory')) {
New-Item -ItemType Directory -Path $NetlogonDest -Force | Out-Null
Write-UpdateLog "Created: $NetlogonDest"
}
}
$repoVer = Join-Path $RepoPath 'version.txt'
if (-not (Test-Path -LiteralPath $repoVer)) {
throw "Missing version.txt in repo: $repoVer"
}
$expectedVer = (Get-Content -LiteralPath $repoVer -Raw).Trim()
Write-UpdateLog "Repo version.txt: $expectedVer"
foreach ($name in $DistFiles) {
$src = Join-Path $RepoPath $name
if (-not (Test-Path -LiteralPath $src)) {
throw "Missing in repo: $src"
}
$dst = Join-Path $NetlogonDest $name
if ($PSCmdlet.ShouldProcess($dst, "Copy from $src")) {
Copy-FileToNetlogon -SourcePath $src -DestPath $dst
Write-UpdateLog "Copied: $name -> $NetlogonDest"
}
}
$verFile = Join-Path $NetlogonDest 'version.txt'
if (Test-Path -LiteralPath $verFile) {
$ver = (Get-Content -LiteralPath $verFile -Raw).Trim()
Write-UpdateLog "NETLOGON version: $ver"
if ($ver -ne $expectedVer) {
Write-UpdateLog "ERROR: NETLOGON version mismatch (expected $expectedVer)"
throw "NETLOGON version.txt is $ver, expected $expectedVer"
}
}
}
try {
Write-UpdateLog '=== RDP-login-monitor: git pull -> NETLOGON ==='
Ensure-GitAvailable
Update-Repository
Publish-DistributionFiles
Write-UpdateLog '=== Done ==='
exit 0
} catch {
Write-UpdateLog "ERROR: $($_.Exception.Message)"
exit 1
}
+1
View File
@@ -0,0 +1 @@
2.1.15-SAC