Files
linux-scripts/users-logged-in.ps1
T

693 lines
26 KiB
PowerShell

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