<# .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 }