Add all 44 scripts, update CI: error severity baseline, PowerShell validation, multi-distro testing
Amp-Thread-ID: https://ampcode.com/threads/T-019cc404-c628-759e-a50b-f5eeea35b91f Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
@@ -0,0 +1,692 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Monitors RDP user sessions and exports metrics for Prometheus windows_exporter.
|
||||
|
||||
.DESCRIPTION
|
||||
This script monitors the number of active RDP user sessions and creates Prometheus-formatted metrics.
|
||||
The metrics are written to a text file that can be consumed by the windows_exporter.
|
||||
It can also run periodically.
|
||||
|
||||
.PARAMETER MetricsPath
|
||||
The path where the Prometheus metrics file will be written.
|
||||
|
||||
.PARAMETER IntervalSeconds
|
||||
The interval in seconds for the scheduled task. Default is 60 seconds.
|
||||
|
||||
.Parameter RunOnce
|
||||
Switch to run the script once and exit instead of creating a scheduled task.
|
||||
|
||||
.PARAMETER Debug
|
||||
Switch to run the script in debug mode.
|
||||
|
||||
.PARAMETER RunOnce
|
||||
Switch to run the script once and exit instead of creating a scheduled task.
|
||||
|
||||
.PARAMETER DryRun
|
||||
Switch to output metrics to console instead of writing to file.
|
||||
|
||||
.PARAMETER Verbose
|
||||
Switch to enable verbose debug output.
|
||||
|
||||
.PARAMETER Quiet
|
||||
Switch to suppress non-error output.
|
||||
|
||||
.PARAMETER NoSchedule
|
||||
Switch to skip scheduled task creation.
|
||||
|
||||
.PARAMETER Version
|
||||
Switch to display script version and exit.
|
||||
|
||||
.NOTES
|
||||
Version: 1.1.2-20251002
|
||||
Author: Phil Connor contact@mylinux.work
|
||||
|
||||
Features:
|
||||
- Monitors active RDP user sessions using quser command
|
||||
- Captures username, session name, session ID, state (Active/Disconnected), idle time, and logon time
|
||||
- Attempts to correlate session IDs with client IP addresses using qwinsta
|
||||
- Writes metrics to a text file for consumption by windows_exporter.
|
||||
- Reads last 10 PowerShell commands from each user's PSReadline history file.
|
||||
#>
|
||||
|
||||
param(
|
||||
[ValidateScript({
|
||||
if ($_ -and -not (Test-Path (Split-Path $_ -Parent))) {
|
||||
throw "Directory for metrics path does not exist: $(Split-Path $_ -Parent)"
|
||||
}
|
||||
return $true
|
||||
})]
|
||||
[string]$MetricsPath = "C:\Program Files\windows_exporter\textfile_inputs\users_logged_in.prom",
|
||||
[int]$IntervalSeconds = 60,
|
||||
[switch]$RunOnce,
|
||||
[switch]$Debug,
|
||||
[switch]$DryRun,
|
||||
[switch]$Verbose,
|
||||
[switch]$Quiet,
|
||||
[switch]$NoSchedule,
|
||||
[switch]$Version
|
||||
)
|
||||
|
||||
# Handle version display
|
||||
if ($Version) {
|
||||
Write-Host "Windows RDP User Monitor PowerShell Script"
|
||||
Write-Host "Version: 1.1.0-20250915"
|
||||
Write-Host "Author: Phil Connor contact@mylinux.work"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Set up logging preferences based on Verbose/Quiet flags
|
||||
if ($Verbose) {
|
||||
$VerbosePreference = 'Continue'
|
||||
$InformationPreference = 'Continue'
|
||||
}
|
||||
if ($Quiet) {
|
||||
$VerbosePreference = 'SilentlyContinue'
|
||||
$InformationPreference = 'SilentlyContinue'
|
||||
$WarningPreference = 'SilentlyContinue'
|
||||
}
|
||||
|
||||
# Enhanced logging functions
|
||||
function Write-InfoLog {
|
||||
param([string]$Message)
|
||||
if (-not $Quiet) {
|
||||
Write-Host "[INFO] $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') $Message" -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
function Write-VerboseLog {
|
||||
param([string]$Message)
|
||||
if ($Verbose) {
|
||||
Write-Host "[VERBOSE] $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') $Message" -ForegroundColor Cyan
|
||||
}
|
||||
}
|
||||
|
||||
# Configuration constants for the script
|
||||
$script:Config = @{
|
||||
RDP_SESSION_PATTERN = "rdp-tcp#\d+|console" # Regex pattern to match RDP session names
|
||||
METRIC_NAME = "windows_rdp_users_logged_in" # Primary Prometheus metric name
|
||||
QWINSTA_IP_REGEX = '^\s*(\S+)\s+(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\d+\.\d+\.\d+\.\d+)' # Pattern for IP extraction
|
||||
QUSER_HEADER_REGEX = "USERNAME.*SESSIONNAME.*ID.*STATE" # Expected quser output header format
|
||||
COLUMNS = @{ # Column positions in quser output
|
||||
USERNAME = 0; SESSION = 1; ID = 2; STATE = 3; IDLE = 4; LOGON_START = 5
|
||||
}
|
||||
}
|
||||
|
||||
# Sanitize string values for use as Prometheus metric labels
|
||||
# Removes or replaces characters that would break Prometheus metric format
|
||||
function ConvertTo-MetricLabel {
|
||||
param([AllowEmptyString()][string]$Value)
|
||||
if ([string]::IsNullOrEmpty($Value)) { return "" }
|
||||
|
||||
# Replace problematic characters with underscores to prevent metric parsing issues
|
||||
$sanitized = $Value -replace '["\\\n\r\t>]', '_'
|
||||
|
||||
# Limit length to prevent overly long metric labels (Prometheus best practice)
|
||||
if ($sanitized.Length -gt 200) {
|
||||
$sanitized = $sanitized.Substring(0, 200) + "..."
|
||||
}
|
||||
return $sanitized
|
||||
}
|
||||
|
||||
# Format metric data into Prometheus text format
|
||||
function Write-PrometheusMetric {
|
||||
param(
|
||||
[ValidateNotNullOrEmpty()][string]$Name,
|
||||
[ValidateNotNullOrEmpty()][string]$Help,
|
||||
[ValidateNotNullOrEmpty()][string]$Type,
|
||||
[ValidateNotNull()][array]$Metrics
|
||||
)
|
||||
|
||||
try {
|
||||
@(
|
||||
# Write Prometheus metric header with help text and type
|
||||
"# HELP $Name $Help"
|
||||
"# TYPE $Name $Type"
|
||||
# Format each metric with its labels and value
|
||||
$Metrics | ForEach-Object {
|
||||
if ($null -eq $_ -or $null -eq $_.Labels -or $null -eq $_.Value) {
|
||||
throw "Invalid metric data"
|
||||
}
|
||||
"$Name$($_.Labels) $($_.Value)"
|
||||
}
|
||||
)
|
||||
}
|
||||
catch {
|
||||
Write-Error "Failed to write metric: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
# Execute quser command and validate output format
|
||||
# Returns raw quser command output after basic validation
|
||||
function Get-QUserData {
|
||||
try {
|
||||
# Run quser command and suppress stderr to avoid noise
|
||||
$output = quser 2>$null
|
||||
|
||||
# Validate that we got some output
|
||||
if (-not $output -or $output.Count -eq 0) {
|
||||
throw "No user sessions found or quser command failed"
|
||||
}
|
||||
|
||||
# Ensure output has expected header format
|
||||
if ($output.Count -lt 2 -or $output[0] -notmatch $script:Config.QUSER_HEADER_REGEX) {
|
||||
throw "Unexpected quser output format"
|
||||
}
|
||||
return $output
|
||||
}
|
||||
catch [System.Management.Automation.CommandNotFoundException] {
|
||||
throw "quser command not found. This script requires Windows with Terminal Services."
|
||||
}
|
||||
}
|
||||
|
||||
# Get IP addresses for RDP sessions using qwinsta command
|
||||
# Attempts to correlate session IDs with client IP addresses for remote sessions
|
||||
function Get-SessionIPAddresses {
|
||||
try {
|
||||
$sessionIPs = @{}
|
||||
# Run qwinsta to get session information including IP addresses
|
||||
$qwinstaOutput = qwinsta 2>$null
|
||||
|
||||
if ($qwinstaOutput) {
|
||||
Write-Verbose "Raw qwinsta output:"
|
||||
$qwinstaOutput | ForEach-Object { Write-Verbose " $_" }
|
||||
|
||||
foreach ($line in $qwinstaOutput) {
|
||||
# Skip header lines and empty lines
|
||||
if ([string]::IsNullOrWhiteSpace($line) -or $line -match '^\s*SESSIONNAME') {
|
||||
continue
|
||||
}
|
||||
|
||||
Write-Verbose "Processing qwinsta line: '$line'"
|
||||
|
||||
# Look for any IP address in the line and try to correlate with session ID
|
||||
if ($line -match '(\d+\.\d+\.\d+\.\d+)') {
|
||||
$ipAddress = $matches[1]
|
||||
|
||||
# Try different patterns to find session ID that corresponds to this IP
|
||||
$sessionId = $null
|
||||
|
||||
# Pattern 1: Standard format with session ID as 3rd column
|
||||
if ($line -match '^\s*(\S+)\s+(\S+)?\s+(\d+)\s+') {
|
||||
$sessionId = $matches[3]
|
||||
}
|
||||
# Pattern 2: RDP session format
|
||||
elseif ($line -match 'rdp-tcp#\d+.*?\s(\d+)\s+') {
|
||||
$sessionId = $matches[1]
|
||||
}
|
||||
# Pattern 3: Any number that looks like a session ID (between spaces)
|
||||
elseif ($line -match '\s(\d+)\s+\w+') {
|
||||
$sessionId = $matches[1]
|
||||
}
|
||||
|
||||
# Store the mapping if we found a valid session ID
|
||||
if ($sessionId) {
|
||||
$sessionIPs[$sessionId] = $ipAddress
|
||||
Write-Verbose "Mapped session ID $sessionId to IP $ipAddress"
|
||||
}
|
||||
else {
|
||||
Write-Verbose "Found IP $ipAddress but could not determine session ID"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Verbose "Final session IP mapping: $($sessionIPs | ConvertTo-Json -Compress)"
|
||||
return $sessionIPs
|
||||
}
|
||||
catch {
|
||||
# Don't fail the entire script if IP detection fails
|
||||
Write-Warning "Failed to get session IP addresses: $($_.Exception.Message)"
|
||||
return @{}
|
||||
}
|
||||
}
|
||||
|
||||
# Parses a single line of quser output into a structured object
|
||||
# Converts space-separated quser output into a PowerShell object with named properties
|
||||
function ConvertFrom-QUserLine {
|
||||
param(
|
||||
[ValidateNotNullOrEmpty()][string]$Line,
|
||||
[hashtable]$SessionIPs = @{}
|
||||
)
|
||||
|
||||
# Split the line into fields, normalizing whitespace
|
||||
$fields = $Line.Trim() -Replace '\s+', ' ' -Split '\s'
|
||||
|
||||
# Validate minimum expected field count
|
||||
if ($fields.Length -lt 6) { return $null }
|
||||
|
||||
$cols = $script:Config.COLUMNS
|
||||
$sessionId = $fields[$cols.ID]
|
||||
|
||||
# Look up IP address for this session if available
|
||||
$ipAddress = if ($SessionIPs.ContainsKey($sessionId)) { $SessionIPs[$sessionId] } else { "unknown" }
|
||||
|
||||
# Extract logon time from remaining fields (may span multiple columns)
|
||||
$logonTime = if ($fields.Length -gt $cols.LOGON_START) {
|
||||
$endIndex = if ($fields.Length -gt 6) { $fields.Length - 2 } else { $fields.Length - 1 }
|
||||
$fields[$cols.LOGON_START..$endIndex] -join ' '
|
||||
}
|
||||
else { "Unknown" }
|
||||
|
||||
# Clean username by removing leading ">" character if present (indicates active session)
|
||||
$cleanUserName = $fields[$cols.USERNAME] -replace '^>', ''
|
||||
|
||||
# Create structured object with all session information
|
||||
return [PSCustomObject]@{
|
||||
UserName = $cleanUserName
|
||||
SessionName = $fields[$cols.SESSION]
|
||||
ID = $sessionId
|
||||
State = $fields[$cols.STATE]
|
||||
IdleTime = $fields[$cols.IDLE]
|
||||
LogonTime = $logonTime
|
||||
ClientLocation = if ($fields.Length -gt 6) { $fields[-1] } else { "local" }
|
||||
IPAddress = $ipAddress
|
||||
}
|
||||
}
|
||||
|
||||
# Get command history for a specific user session
|
||||
# Retrieves recent PowerShell commands from the user's PSReadline history file
|
||||
function Get-UserCommandHistory {
|
||||
param(
|
||||
[string]$UserName,
|
||||
[string]$SessionId,
|
||||
[int]$MaxCommands = 10
|
||||
)
|
||||
|
||||
try {
|
||||
# Sanitize username to remove invalid file path characters
|
||||
$sanitizedUserName = $UserName -replace '[<>:"|?*]', '_'
|
||||
|
||||
# Try to get PowerShell history from the user's profile
|
||||
$historyPath = "C:\Users\$sanitizedUserName\AppData\Roaming\Microsoft\Windows\PowerShell\PSReadline\ConsoleHost_history.txt"
|
||||
$commands = @()
|
||||
|
||||
# Check if PowerShell history file exists
|
||||
if (Test-Path $historyPath) {
|
||||
# Read the last N commands from the history file
|
||||
$historyContent = Get-Content $historyPath -Tail $MaxCommands -ErrorAction SilentlyContinue
|
||||
if ($historyContent) {
|
||||
# Clean up the commands by trimming whitespace and removing empty lines
|
||||
$commands = $historyContent | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne "" }
|
||||
}
|
||||
}
|
||||
|
||||
# If no PowerShell history, try to get CMD history using doskey
|
||||
if ($commands.Count -eq 0) {
|
||||
try {
|
||||
# Use query session to check if user is active, then try to get command history
|
||||
$sessionInfo = query session $SessionId 2>$null
|
||||
if ($sessionInfo) {
|
||||
# This is a simplified approach - in practice, CMD history is harder to access remotely
|
||||
$commands = @("No recent command history available")
|
||||
}
|
||||
}
|
||||
catch {
|
||||
$commands = @("Unable to retrieve command history")
|
||||
}
|
||||
}
|
||||
|
||||
# Return the most recent commands up to the specified limit
|
||||
return $commands | Select-Object -First $MaxCommands
|
||||
}
|
||||
catch {
|
||||
Write-Verbose "Failed to get command history for user $UserName (Session $SessionId): $($_.Exception.Message)"
|
||||
return @("Command history unavailable")
|
||||
}
|
||||
}
|
||||
|
||||
# Get all active RDP user sessions with detailed information
|
||||
# Combines quser and qwinsta data to create comprehensive user session objects
|
||||
function Get-RDPUsers {
|
||||
try {
|
||||
# Get raw user session data and IP address mappings
|
||||
$qUserOutput = Get-QUserData
|
||||
$sessionIPs = Get-SessionIPAddresses
|
||||
Write-Verbose "Found $($qUserOutput.Count) total user sessions"
|
||||
Write-Verbose "Found $($sessionIPs.Count) session IP addresses"
|
||||
|
||||
# Process each user session line (skip header line)
|
||||
$allUsers = $qUserOutput | Select-Object -Skip 1 | ForEach-Object {
|
||||
# Parse the quser output line into a structured object
|
||||
$user = ConvertFrom-QUserLine $_ $sessionIPs
|
||||
if ($null -eq $user) {
|
||||
Write-Warning "Skipping malformed quser output: $_"
|
||||
return
|
||||
}
|
||||
|
||||
# Add command history to user object
|
||||
$commandHistory = Get-UserCommandHistory -UserName $user.UserName -SessionId $user.ID
|
||||
$user | Add-Member -NotePropertyName "CommandHistory" -NotePropertyValue $commandHistory
|
||||
|
||||
$user
|
||||
} | Where-Object { $_ }
|
||||
|
||||
# Filter to only RDP sessions (excluding services and other non-user sessions)
|
||||
$rdpUsers = $allUsers | Where-Object {
|
||||
$_.SessionName -match $script:Config.RDP_SESSION_PATTERN -and
|
||||
![string]::IsNullOrEmpty($_.UserName) -and
|
||||
![string]::IsNullOrEmpty($_.SessionName) -and
|
||||
![string]::IsNullOrEmpty($_.State)
|
||||
}
|
||||
|
||||
Write-Verbose "Processed $($allUsers.Count) valid user sessions"
|
||||
Write-Verbose "Filtered to $($rdpUsers.Count) RDP sessions"
|
||||
|
||||
return $rdpUsers
|
||||
}
|
||||
catch {
|
||||
throw "Failed to collect user data: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
# Creates Prometheus metrics from user session data
|
||||
# Transforms user session objects into Prometheus-formatted metric data
|
||||
function New-UserMetrics {
|
||||
param([array]$Users)
|
||||
|
||||
if (-not $Users) { return @() }
|
||||
|
||||
# Initialize counters and collections for metric generation
|
||||
$stateCount = @{ Active = 0; Disc = 0 }
|
||||
$usernames = @()
|
||||
$userMetrics = @()
|
||||
$commandMetrics = @()
|
||||
|
||||
# Process each user to create individual metrics
|
||||
foreach ($user in $Users) {
|
||||
if ($null -eq $user) {
|
||||
Write-Warning "Found null user in collection"
|
||||
continue
|
||||
}
|
||||
|
||||
# Track state counts for summary metrics
|
||||
$stateCount[$user.State]++
|
||||
$usernames += $user.UserName
|
||||
|
||||
# Create individual user session metric
|
||||
$userMetrics += @{
|
||||
Labels = "{username=`"$(ConvertTo-MetricLabel $user.UserName)`",session=`"$(ConvertTo-MetricLabel $user.SessionName)`",state=`"$($user.State)`",location=`"$(ConvertTo-MetricLabel $user.ClientLocation)`",ip=`"$(ConvertTo-MetricLabel $user.IPAddress)`"}"
|
||||
Value = 1
|
||||
}
|
||||
|
||||
# Add command history metrics for each user
|
||||
if ($user.CommandHistory -and $user.CommandHistory.Count -gt 0) {
|
||||
for ($i = 0; $i -lt $user.CommandHistory.Count; $i++) {
|
||||
$command = ConvertTo-MetricLabel $user.CommandHistory[$i]
|
||||
$commandMetrics += @{
|
||||
Labels = "{username=`"$(ConvertTo-MetricLabel $user.UserName)`",session=`"$(ConvertTo-MetricLabel $user.SessionName)`",command_index=`"$($i + 1)`",command=`"$command`"}"
|
||||
Value = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Create summary metrics with totals and user list
|
||||
$summaryMetrics = @(
|
||||
@{ Labels = '{metric="total"}'; Value = $Users.Count }
|
||||
@{ Labels = '{metric="active"}'; Value = $stateCount.Active }
|
||||
@{ Labels = '{metric="disconnected"}'; Value = $stateCount.Disc }
|
||||
@{ Labels = '{metric="users_list",users="' + $(ConvertTo-MetricLabel (($usernames | Sort-Object) -join ',')) + '"}'; Value = 1 }
|
||||
)
|
||||
|
||||
# Combine all metric types into a single collection
|
||||
return $summaryMetrics + $userMetrics + $commandMetrics
|
||||
}
|
||||
|
||||
# Write metrics content to file using atomic write operation
|
||||
function Write-MetricsFile {
|
||||
param(
|
||||
[ValidateNotNull()]$Content,
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
if (-not $Path) {
|
||||
return $Content
|
||||
}
|
||||
|
||||
# Ensure the directory exists
|
||||
$directory = Split-Path $Path -Parent
|
||||
if ($directory -and -not (Test-Path $directory)) {
|
||||
try {
|
||||
New-Item -Path $directory -ItemType Directory -Force | Out-Null
|
||||
Write-Verbose "Created directory: $directory"
|
||||
}
|
||||
catch {
|
||||
Write-Error "Failed to create directory '$directory': $($_.Exception.Message)"
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
$tempPath = "$Path.tmp"
|
||||
try {
|
||||
if ($Content -is [array]) {
|
||||
$Content -join "`n" | Out-File -FilePath $tempPath -Encoding UTF8
|
||||
} else {
|
||||
$Content | Out-File -FilePath $tempPath -Encoding UTF8
|
||||
}
|
||||
Move-Item -Path $tempPath -Destination $Path -Force -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
Write-Error "Failed to write metrics file: $($_.Exception.Message)"
|
||||
if (Test-Path $tempPath) { Remove-Item $tempPath -Force }
|
||||
}
|
||||
}
|
||||
|
||||
# Main function that orchestrates the complete metrics collection process
|
||||
# Coordinates all data collection, processing, and output generation
|
||||
function Invoke-MetricsCollection {
|
||||
$startTime = Get-Date
|
||||
|
||||
# Add dry-run header if applicable
|
||||
if ($DryRun) {
|
||||
Write-Host "=== DRY RUN MODE - Metrics that would be written to $MetricsPath ===" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
try {
|
||||
# Collect RDP user session data
|
||||
Write-VerboseLog "Collecting RDP user session data..."
|
||||
$rdpUsers = Get-RDPUsers
|
||||
if ($null -eq $rdpUsers) {
|
||||
throw "Get-RDPUsers returned null"
|
||||
}
|
||||
|
||||
Write-VerboseLog "Found $($rdpUsers.Count) RDP users"
|
||||
|
||||
# Convert user data to Prometheus metrics
|
||||
$metrics = New-UserMetrics -Users $rdpUsers
|
||||
if ($null -eq $metrics) {
|
||||
throw "New-UserMetrics returned null"
|
||||
}
|
||||
|
||||
# Collect failed login attempts
|
||||
Write-VerboseLog "Collecting failed login data..."
|
||||
$failedLoginMetrics = Get-FailedLogins
|
||||
|
||||
# Calculate script execution time for performance monitoring
|
||||
$endTime = Get-Date
|
||||
$executionTimeMs = [math]::Round(($endTime - $startTime).TotalMilliseconds, 2)
|
||||
|
||||
# Add execution time metric for monitoring script performance
|
||||
$executionMetric = @{
|
||||
Labels = '{metric="execution_time_ms"}'
|
||||
Value = $executionTimeMs
|
||||
}
|
||||
$metrics += $executionMetric
|
||||
|
||||
# Split metrics into different types
|
||||
$userMetrics = $metrics | Where-Object { $_.Labels -notmatch 'command=' }
|
||||
$commandMetrics = $metrics | Where-Object { $_.Labels -match 'command=' }
|
||||
|
||||
# Generate Prometheus-formatted output
|
||||
$output = @()
|
||||
$output += Write-PrometheusMetric -Name $script:Config.METRIC_NAME -Help "Number of RDP users currently logged in" -Type "gauge" -Metrics $userMetrics
|
||||
|
||||
# Add command history metrics as a separate metric family
|
||||
if ($commandMetrics.Count -gt 0) {
|
||||
$output += Write-PrometheusMetric -Name "windows_rdp_user_command_history" -Help "Recent command history for RDP users" -Type "gauge" -Metrics $commandMetrics
|
||||
}
|
||||
|
||||
# Add failed login metrics
|
||||
if ($failedLoginMetrics.Count -gt 0) {
|
||||
$output += Write-PrometheusMetric -Name "windows_user_failed_logins" -Help "Failed login attempts from Windows Event Log" -Type "counter" -Metrics $failedLoginMetrics
|
||||
}
|
||||
|
||||
if ($null -eq $output) {
|
||||
throw "Write-PrometheusMetric returned null"
|
||||
}
|
||||
|
||||
Write-VerboseLog "Metrics collection completed (execution time: ${executionTimeMs}ms)"
|
||||
|
||||
# Output to console and/or file based on mode
|
||||
if ($DryRun) {
|
||||
Write-Host $output
|
||||
Write-Host "=== END DRY RUN OUTPUT ===" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Output $output
|
||||
Write-MetricsFile -Content $output -Path $MetricsPath
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Error "Failed to collect metrics: $($_.Exception.Message)"
|
||||
# Attempt to write partial results if available
|
||||
if ($MetricsPath -and $output -and -not $DryRun) {
|
||||
$output | Out-File -FilePath $MetricsPath -Encoding UTF8
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Register cleanup handler for graceful shutdown
|
||||
Register-EngineEvent -SourceIdentifier PowerShell.Exiting -Action {
|
||||
Write-Host "Shutting down gracefully..."
|
||||
}
|
||||
|
||||
# Create scheduled task for periodic execution
|
||||
function New-MetricsScheduledTask {
|
||||
param(
|
||||
[int]$IntervalSeconds = 60,
|
||||
[string]$TaskName = "PrometheusRDPMetrics"
|
||||
)
|
||||
|
||||
try {
|
||||
# Check if scheduled task already exists
|
||||
if (Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue) {
|
||||
Write-InfoLog "Scheduled task '$TaskName' already exists. Skipping creation."
|
||||
return
|
||||
}
|
||||
|
||||
$principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest
|
||||
$action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument "-NoProfile -ExecutionPolicy Bypass -File `"$($MyInvocation.MyCommand.Path)`" -MetricsPath `"$MetricsPath`" -RunOnce"
|
||||
$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Seconds $IntervalSeconds)
|
||||
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable
|
||||
|
||||
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force
|
||||
Write-InfoLog "Scheduled task '$TaskName' created successfully with $IntervalSeconds second interval"
|
||||
}
|
||||
catch {
|
||||
Write-Error "Failed to create scheduled task: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
# Debug function to test qwinsta parsing
|
||||
function Test-QwinstaOutput {
|
||||
Write-Host "=== Testing qwinsta output parsing ===" -ForegroundColor Cyan
|
||||
|
||||
try {
|
||||
$qwinstaOutput = qwinsta 2>$null
|
||||
Write-Host "Raw qwinsta output:" -ForegroundColor Yellow
|
||||
$qwinstaOutput | ForEach-Object { Write-Host " $_" }
|
||||
|
||||
Write-Host "`nTesting IP address extraction:" -ForegroundColor Yellow
|
||||
$sessionIPs = Get-SessionIPAddresses
|
||||
$sessionIPs.GetEnumerator() | ForEach-Object {
|
||||
Write-Host " Session ID $($_.Key) -> IP $($_.Value)" -ForegroundColor Green
|
||||
}
|
||||
|
||||
Write-Host "`nTesting quser output:" -ForegroundColor Yellow
|
||||
$quserOutput = quser 2>$null
|
||||
$quserOutput | ForEach-Object { Write-Host " $_" }
|
||||
|
||||
}
|
||||
catch {
|
||||
Write-Error "Test failed: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
# Get failed login attempts from Windows Event Log
|
||||
function Get-FailedLogins {
|
||||
try {
|
||||
$failedLogins = @()
|
||||
$24HoursAgo = (Get-Date).AddHours(-24)
|
||||
|
||||
# Query Windows Security Event Log for failed logon attempts (Event ID 4625)
|
||||
$failedLogonEvents = Get-WinEvent -FilterHashtable @{
|
||||
LogName = 'Security'
|
||||
Id = 4625 # Failed logon attempts
|
||||
StartTime = $24HoursAgo
|
||||
} -ErrorAction SilentlyContinue | Select-Object -First 50
|
||||
|
||||
if ($failedLogonEvents) {
|
||||
foreach ($event in $failedLogonEvents) {
|
||||
try {
|
||||
$eventXml = [xml]$event.ToXml()
|
||||
$eventData = $eventXml.Event.EventData.Data
|
||||
|
||||
# Extract relevant information from event data
|
||||
$targetUserName = ($eventData | Where-Object {$_.Name -eq 'TargetUserName'}).'#text'
|
||||
$workstationName = ($eventData | Where-Object {$_.Name -eq 'WorkstationName'}).'#text'
|
||||
$sourceNetworkAddress = ($eventData | Where-Object {$_.Name -eq 'IpAddress'}).'#text'
|
||||
$failureReason = ($eventData | Where-Object {$_.Name -eq 'SubStatus'}).'#text'
|
||||
|
||||
# Clean up values
|
||||
if ([string]::IsNullOrWhiteSpace($targetUserName)) { $targetUserName = "unknown" }
|
||||
if ([string]::IsNullOrWhiteSpace($sourceNetworkAddress) -or $sourceNetworkAddress -eq '-') { $sourceNetworkAddress = "local" }
|
||||
if ([string]::IsNullOrWhiteSpace($workstationName)) { $workstationName = "unknown" }
|
||||
|
||||
# Determine failure type based on sub status
|
||||
$failureType = switch ($failureReason) {
|
||||
"0xC0000064" { "invalid_user" }
|
||||
"0xC000006A" { "wrong_password" }
|
||||
"0xC0000234" { "account_locked" }
|
||||
"0xC0000072" { "account_disabled" }
|
||||
"0xC000006F" { "logon_time_restriction" }
|
||||
"0xC0000070" { "workstation_restriction" }
|
||||
default { "other_failure" }
|
||||
}
|
||||
|
||||
$failedLogins += @{
|
||||
Labels = "{username=`"$targetUserName`",source_ip=`"$sourceNetworkAddress`",workstation=`"$workstationName`",failure_type=`"$failureType`"}"
|
||||
Value = 1
|
||||
}
|
||||
} catch {
|
||||
Write-VerboseLog "Failed to parse event: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $failedLogins
|
||||
} catch {
|
||||
Write-Warning "Failed to get failed login events: $($_.Exception.Message)"
|
||||
return @()
|
||||
}
|
||||
}
|
||||
|
||||
# Main execution logic - determines script behavior based on parameters
|
||||
if ($Debug) {
|
||||
# Debug mode: test qwinsta and quser output parsing
|
||||
Test-QwinstaOutput
|
||||
}
|
||||
elseif ($RunOnce -or $DryRun) {
|
||||
# Single execution mode: collect metrics once and exit
|
||||
Invoke-MetricsCollection
|
||||
}
|
||||
else {
|
||||
# Scheduled mode: create scheduled task (unless NoSchedule) and run immediately
|
||||
if (-not $NoSchedule) {
|
||||
New-MetricsScheduledTask -IntervalSeconds $IntervalSeconds
|
||||
} else {
|
||||
Write-InfoLog "Skipping scheduled task creation (-NoSchedule specified)"
|
||||
}
|
||||
|
||||
# Run metrics collection immediately
|
||||
Invoke-MetricsCollection
|
||||
}
|
||||
Reference in New Issue
Block a user