Загрузка данных


function Get-DCLogonEvents {
<#
.SYNOPSIS
    Ищет события 4624 для списка учётных записей: один XPath на одну УЗ,
    до $ThrottleLimit параллельных запросов одновременно.

.DESCRIPTION
    Каждая задача = (учётная запись × DC). Задача исполняется в отдельном
    runspace из пула. Внутри runspace выполняется Get-WinEvent -ComputerName
    -FilterXPath: фильтрация происходит на самом DC, клиенту передаются
    только совпавшие события.

    Задачи, превысившие $TimeoutSeconds, принудительно останавливаются
    (PS.Stop()), результат по ним отбрасывается — чтобы не блокировать этап.
#>
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string[]]$AccountSams,
        [Parameter(Mandatory)][string]$DomainName,
        [int]$Days = 30,
        [int]$MaxEventsPerDC = 100000,
        [int]$TimeoutSeconds = 300,
        [int]$ThrottleLimit = 10
    )

    $results = New-Object System.Collections.Generic.List[object]

    if (-not $AccountSams -or $AccountSams.Count -eq 0) {
        Write-ScanLog "  Нет учётных записей — этап пропущен." -Level Warning
        return $results
    }

    # ---------- Список DC ----------
    Write-ScanLog "  Поиск DC для домена '$DomainName'..."
    try {
        $dcs = @(Get-ADDomainController -Server $DomainName -Filter * -ErrorAction Stop |
                 Select-Object -ExpandProperty Name)
    } catch {
        Write-ScanLog "  ОШИБКА при получении списка DC: $_" -Level Error
        return $results
    }
    if ($dcs.Count -eq 0) {
        Write-ScanLog "  DC не найдены — этап пропущен." -Level Warning
        return $results
    }
    Write-ScanLog "  DC ($($dcs.Count)): $($dcs -join ', ')" -Level Success

    # ---------- Построение списка задач (account × DC) ----------
    $startTimeUtc = (Get-Date).AddDays(-$Days).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ")

    # LogonType — общий фильтр для всех задач (строковый литерал, не зависит от УЗ)
    $logonCond = "Data[@Name='LogonType' and (Data='3' or Data='4' or Data='5' " +
                 "or Data='8' or Data='9' or Data='10')]"

    $tasks = New-Object System.Collections.Generic.List[object]
    foreach ($acc in $AccountSams) {
        # В XPath одинарная кавычка экранируется удвоением (''), НЕ через SecurityElement
        $escaped = $acc.Replace("'", "''")
        $userCond = "Data[@Name='TargetUserName' and (Data='$escaped')]"
        # Итоговый XPath короткий — только одна УЗ. Проблема с лимитом длины исчезает.
        $xpath = "*[System[(EventID=4624) and TimeCreated[@SystemTime>='$startTimeUtc']] " +
                 "and EventData[$userCond and $logonCond]]"

        foreach ($dc in $dcs) {
            $tasks.Add([PSCustomObject]@{
                Account = $acc
                DC      = $dc
                XPath   = $xpath
            })
        }
    }

    Write-ScanLog "  Задач к выполнению: $($tasks.Count) ($($AccountSams.Count) УЗ × $($dcs.Count) DC)"
    Write-ScanLog "  Параллельно: $ThrottleLimit, таймаут на задачу: ${TimeoutSeconds}с"

    # ---------- Runspace pool ----------
    $pool = [runspacefactory]::CreateRunspacePool(1, $ThrottleLimit)
    $pool.Open()

    $jobs = New-Object System.Collections.Generic.List[object]
    $sw   = [System.Diagnostics.Stopwatch]::StartNew()

    try {
        # Отправляем все задачи в пул. Пул сам ограничит параллелизм до $ThrottleLimit.
        $submitted = 0
        foreach ($task in $tasks) {
            $ps = [powershell]::Create()
            $ps.RunspacePool = $pool
            $null = $ps.AddScript({
                param($dc, $xpath, $max)
                # Выполняется внутри runspace. Get-WinEvent -ComputerName работает
                # по RPC, фильтрация XPath — на самом DC, поэтому передаётся только
                # то, что реально совпало. Это критично для скорости.
                try {
                    $evts = Get-WinEvent -ComputerName $dc -LogName 'Security' `
                                -FilterXPath $xpath -MaxEvents $max -ErrorAction Stop
                    $evts | ForEach-Object {
                        [PSCustomObject]@{
                            TargetUser = $_.Properties[5].Value
                            LogonType  = $_.Properties[8].Value
                            SourceHost = $_.Properties[18].Value
                        }
                    }
                } catch {
                    # «События не найдены» — штатная ситуация, не считаем ошибкой
                    if ($_.FullyQualifiedErrorId -match 'NoMatchingEventsFound' -or
                        $_.Exception.Message        -match 'No events were found') {
                        return
                    }
                    [PSCustomObject]@{ Error = $_.Exception.Message }
                }
            }).AddParameter('dc',    $task.DC) `
              .AddParameter('xpath', $task.XPath) `
              .AddParameter('max',   $MaxEventsPerDC)

            $handle = $ps.BeginInvoke()

            $jobs.Add([PSCustomObject]@{
                PS        = $ps
                Handle    = $handle
                Task      = $task
                StartTime = [DateTime]::UtcNow
                Stopped   = $false
            })
            $submitted++

            # Логируем первые несколько отправок, чтобы было видно старт этапа
            if ($submitted -le 5) {
                Write-ScanLog "    → $($task.Account) @ $($task.DC)"
            }
        }

        # ---------- Ожидание с прогрессом и таймаутами ----------
        $total     = $jobs.Count
        $lastBeat  = [DateTime]::UtcNow

        while ($true) {
            $now       = [DateTime]::UtcNow
            $done      = 0
            $running   = 0
            $timedOut  = 0

            foreach ($job in $jobs) {
                if ($job.Handle.IsCompleted) { $done++; continue }

                if ($job.Stopped) { $timedOut++; continue }

                if (($now - $job.StartTime).TotalSeconds -gt $TimeoutSeconds) {
                    Write-ScanLog "    ТАЙМАУТ: $($job.Task.Account) @ $($job.Task.DC) (>${TimeoutSeconds}с) — прервано" -Level Warning
                    try { $job.PS.Stop() } catch {}
                    $job.Stopped = $true
                    $timedOut++
                    continue
                }
                $running++
            }

            $finished = $done + $timedOut

            Write-Progress -Activity "Анализ журналов DC" `
                -Status ("Завершено {0}/{1} | активно {2}, таймаут {3}, прошло {4}с" -f `
                         $finished, $total, $running, $timedOut, [int]$sw.Elapsed.TotalSeconds) `
                -PercentComplete ($(if ($total) { $finished / $total * 100 } else { 100 }))

            # Heartbeat раз в 10 секунд — даже если ничего не завершилось,
            # в консоли видно, что процесс идёт.
            if (($now - $lastBeat).TotalSeconds -ge 10) {
                Write-ScanLog "    [heartbeat] завершено $finished / $total, активно $running, таймаут $timedOut, $([int]$sw.Elapsed.TotalSeconds)с"
                $lastBeat = $now
            }

            if ($finished -ge $total) { break }
            Start-Sleep -Milliseconds 500
        }
        Write-Progress -Activity "Анализ журналов DC" -Completed

    } finally {
        # ---------- Сбор результатов ----------
        $validSams    = [System.Collections.Generic.HashSet[string]]::new(
                            [string[]]$AccountSams,
                            [System.StringComparer]::OrdinalIgnoreCase)
        $eventCount   = 0
        $errCount     = 0
        $tasksWithHit = 0

        foreach ($job in $jobs) {
            $label = "$($job.Task.Account) @ $($job.Task.DC)"

            try {
                if ($job.Stopped) {
                    Write-ScanLog "    [$label] прервано по таймауту" -Level Warning
                    continue
                }

                $out = @($job.PS.EndInvoke($job.Handle))
                $taskEvents = 0

                foreach ($ev in $out) {
                    if ($ev.Error) {
                        Write-ScanLog "    [$label] ОШИБКА: $($ev.Error)" -Level Warning
                        $errCount++
                        continue
                    }
                    if (-not $ev.TargetUser) { continue }
                    if (-not $validSams.Contains($ev.TargetUser)) { continue }

                    $src = $ev.SourceHost
                    if (-not $src -or $src -eq '-') { continue }
                    $src = $src -replace '^\\\\', ''

                    $results.Add([PSCustomObject]@{
                        AccountSam = $ev.TargetUser
                        Server     = $src
                        WhereUsed  = "Журнал входа DC (4624, logon type $($ev.LogonType))"
                    })
                    $taskEvents++
                }

                if ($taskEvents -gt 0) {
                    $tasksWithHit++
                    Write-ScanLog "    [$label] +$taskEvents событий" -Level Success
                }
                $eventCount += $taskEvents
            } catch {
                # Возможно, задача была остановлена — не считаем это критичной ошибкой
                Write-ScanLog "    [$label] не удалось получить результат: $($_.Exception.Message)" -Level Warning
            } finally {
                try { $job.PS.Dispose() } catch {}
            }
        }

        $pool.Close()
        $pool.Dispose()
        $sw.Stop()

        Write-ScanLog "  Сводка: событий $eventCount из $tasksWithHit задач с совпадениями, ошибок $errCount, время $([int]$sw.Elapsed.TotalSeconds)с"
    }

    # ---------- Дедупликация ----------
    # Одна и та же УЗ могла залогиниться с одного сервера на нескольких DC
    # (репликация) — оставляем одну строку.
    $seen    = @{}
    $deduped = New-Object System.Collections.Generic.List[object]
    foreach ($r in $results) {
        $k = "$($r.AccountSam)|$($r.Server)|$($r.WhereUsed)"
        if (-not $seen.ContainsKey($k)) {
            $seen[$k] = $true
            $deduped.Add($r)
        }
    }
    Write-ScanLog "  Уникальных записей после дедупликации: $($deduped.Count)"
    return $deduped
}