EventMonitor/Core/EventWatcher.ps1
|
# ── EventLog Watcher ────────────────────────────────────────────────────────── # Event-driven monitoring using System.Diagnostics.Eventing.Reader.EventLogWatcher. # Events are delivered by the OS the instant they appear — zero polling, zero CPU waste. # # Architecture: # - One watcher per event log (Security, System, OpenSSH, PowerShell, TerminalServices) # - XPath query filters for only the event IDs we care about # - Events dispatched to the appropriate processor function # - Watchdog monitors health and restarts watchers if needed # # Safety: # - Every callback is wrapped in try/catch — a bad event NEVER crashes the watcher # - Watchers are created disabled, then enabled explicitly # - Dispose is always called in finally blocks # - Timeouts on all operations # ── Watcher State ───────────────────────────────────────────────────────────── # Module-scoped hashtable tracking all active watchers and their health. $script:EventWatchers = @{} $script:WatcherHealthLog = [System.Collections.Generic.List[hashtable]]::new() <# .SYNOPSIS Builds an XPath query string for multiple event IDs. .PARAMETER EventIds Array of integer event IDs. .OUTPUTS XPath query string suitable for EventLogQuery. .EXAMPLE New-XPathQuery -EventIds 4624, 4625, 4648 # Returns: *[System[(EventID=4624 or EventID=4625 or EventID=4648)]] #> function New-XPathQuery { [CmdletBinding()] param( [Parameter(Mandatory)] [int[]]$EventIds ) $conditions = ($EventIds | ForEach-Object { "EventID=$_" }) -join ' or ' return "*[System[($conditions)]]" } <# .SYNOPSIS Creates and registers an EventLogWatcher for a specific log and set of event IDs. .DESCRIPTION The watcher subscribes to Windows Event Log notifications and calls the specified callback scriptblock whenever a matching event appears. The watcher is created DISABLED — call Enable-EventWatcher to start it. Safety features: - Callback exceptions are caught and logged, never propagated - Watcher tracks its own health metrics (events processed, errors, last event time) - Watcher can be restarted via Restart-EventWatcher without losing state .PARAMETER WatcherName A unique name for this watcher (used for health tracking and management). .PARAMETER LogName The Windows Event Log name (Security, System, etc.). .PARAMETER EventIds Array of event IDs to filter for. If empty, all events from the log are watched. .PARAMETER XPathQuery Custom XPath query. Overrides EventIds if provided. .PARAMETER OnEvent Scriptblock to execute when a matching event appears. Receives $EventRecord and $SessionId as parameters. .PARAMETER SessionId Monitoring session correlation ID. .OUTPUTS Hashtable with watcher state including Name, Watcher, Health metrics. #> function Register-EventWatcher { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$WatcherName, [Parameter(Mandatory)] [string]$LogName, [int[]]$EventIds, [string]$XPathQuery, [Parameter(Mandatory)] [scriptblock]$OnEvent, [string]$SessionId ) # Build query if (-not $XPathQuery) { if ($EventIds -and $EventIds.Count -gt 0) { $XPathQuery = New-XPathQuery -EventIds $EventIds } else { $XPathQuery = '*' } } try { $query = [System.Diagnostics.Eventing.Reader.EventLogQuery]::new( $LogName, [System.Diagnostics.Eventing.Reader.PathType]::LogName, $XPathQuery ) $watcher = [System.Diagnostics.Eventing.Reader.EventLogWatcher]::new($query) # Create health tracker $health = @{ Name = $WatcherName LogName = $LogName EventIds = $EventIds XPathQuery = $XPathQuery CreatedAt = [DateTime]::UtcNow LastEventAt = $null LastErrorAt = $null EventsProcessed = [long]0 ErrorCount = [long]0 IsEnabled = $false LastError = $null } # Register the event callback with full safety wrapping $callbackState = @{ OnEvent = $OnEvent Health = $health SessionId = $SessionId WatcherName = $WatcherName } $null = Register-ObjectEvent -InputObject $watcher -EventName 'EventRecordWritten' ` -MessageData $callbackState ` -Action { $state = $Event.MessageData try { $record = $Event.SourceEventArgs.EventRecord if ($null -eq $record) { return } # Invoke the processor callback & $state.OnEvent -EventRecord $record -SessionId $state.SessionId $state.Health.EventsProcessed++ $state.Health.LastEventAt = [DateTime]::UtcNow } catch { $state.Health.ErrorCount++ $state.Health.LastErrorAt = [DateTime]::UtcNow $state.Health.LastError = $_.Exception.Message # Log locally — NEVER throw from a callback try { $timestamp = Get-Date -Format 'yyyy-MM-ddTHH:mm:ss' $entry = "$timestamp :: [Error] Watcher '$($state.WatcherName)' callback failed: $($_.Exception.Message)" Add-Content -Path $script:LogFilePath -Value $entry -ErrorAction SilentlyContinue } catch { $null = $null } } } $watcherState = @{ Name = $WatcherName Watcher = $watcher Health = $health Query = $query OnEvent = $OnEvent } $script:EventWatchers[$WatcherName] = $watcherState Write-EMLog -Message "Registered watcher '$WatcherName' for $LogName ($($EventIds.Count) event IDs)" return $watcherState } catch { Write-EMLog -Message "Failed to register watcher '$WatcherName': $($_.Exception.Message)" -Level Error return $null } } <# .SYNOPSIS Enables (starts) a registered event watcher. #> function Enable-EventWatcher { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$WatcherName ) $state = $script:EventWatchers[$WatcherName] if ($null -eq $state) { Write-EMLog -Message "Watcher '$WatcherName' not found." -Level Warning return } try { $state.Watcher.Enabled = $true $state.Health.IsEnabled = $true Write-EMLog -Message "Enabled watcher '$WatcherName'." } catch { Write-EMLog -Message "Failed to enable watcher '$WatcherName': $($_.Exception.Message)" -Level Error } } <# .SYNOPSIS Disables (pauses) a registered event watcher without destroying it. #> function Disable-EventWatcher { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$WatcherName ) $state = $script:EventWatchers[$WatcherName] if ($null -eq $state) { return } try { $state.Watcher.Enabled = $false $state.Health.IsEnabled = $false Write-EMLog -Message "Disabled watcher '$WatcherName'." } catch { Write-EMLog -Message "Failed to disable watcher '$WatcherName': $($_.Exception.Message)" -Level Error } } <# .SYNOPSIS Stops and disposes a watcher, then re-creates it. Used for auto-repair. #> function Restart-EventWatcher { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$WatcherName, [string]$SessionId ) $state = $script:EventWatchers[$WatcherName] if ($null -eq $state) { Write-EMLog -Message "Cannot restart '$WatcherName' — not found." -Level Warning return } Write-EMLog -Message "Restarting watcher '$WatcherName'..." -Level Warning # Dispose old watcher safely try { $state.Watcher.Enabled = $false $state.Watcher.Dispose() } catch { $null = $null } # Unregister old event subscription Get-EventSubscriber | Where-Object { $_.SourceObject -eq $state.Watcher } | Unregister-Event -ErrorAction SilentlyContinue # Re-register with same parameters $newState = Register-EventWatcher ` -WatcherName $WatcherName ` -LogName $state.Health.LogName ` -EventIds $state.Health.EventIds ` -XPathQuery $state.Health.XPathQuery ` -OnEvent $state.OnEvent ` -SessionId $SessionId if ($null -ne $newState) { # Reset error count after successful restart $newState.Health.EventsProcessed = $state.Health.EventsProcessed $newState.Health.ErrorCount = 0 Enable-EventWatcher -WatcherName $WatcherName Write-EMLog -Message "Watcher '$WatcherName' restarted successfully." } } <# .SYNOPSIS Stops and disposes ALL event watchers. Called during shutdown. #> function Stop-AllEventWatchers { [CmdletBinding()] param() foreach ($name in @($script:EventWatchers.Keys)) { try { $state = $script:EventWatchers[$name] $state.Watcher.Enabled = $false $state.Watcher.Dispose() Write-EMLog -Message "Stopped watcher '$name'." } catch { Write-EMLog -Message "Error stopping watcher '$name': $($_.Exception.Message)" -Level Warning } } # Clean up only OUR event subscriptions (don't touch other modules' subscribers) foreach ($name in @($script:EventWatchers.Keys)) { $state = $script:EventWatchers[$name] Get-EventSubscriber -ErrorAction SilentlyContinue | Where-Object { $_.SourceObject -eq $state.Watcher } | Unregister-Event -ErrorAction SilentlyContinue } $script:EventWatchers.Clear() Write-EMLog -Message 'All event watchers stopped.' } <# .SYNOPSIS Returns health status for all registered watchers. .OUTPUTS Array of hashtables with Name, IsEnabled, EventsProcessed, ErrorCount, etc. #> function Get-EventWatcherHealth { [CmdletBinding()] param() foreach ($name in $script:EventWatchers.Keys) { $state = $script:EventWatchers[$name] [PSCustomObject]@{ Name = $state.Health.Name LogName = $state.Health.LogName IsEnabled = $state.Health.IsEnabled EventsProcessed = $state.Health.EventsProcessed ErrorCount = $state.Health.ErrorCount LastEventAt = $state.Health.LastEventAt LastErrorAt = $state.Health.LastErrorAt LastError = $state.Health.LastError UptimeMinutes = [math]::Round(([DateTime]::UtcNow - $state.Health.CreatedAt).TotalMinutes, 1) } } } |