<# .SYNOPSIS Windows LAPS Prometheus Metrics Exporter .DESCRIPTION Prometheus exporter for Windows Local Administrator Password Solution (LAPS). Monitors password expiration, rotation status, coverage across OUs, and stale passwords. Exports metrics for windows_exporter textfile collector or standalone HTTP listener. .PARAMETER TextFile Write to windows_exporter textfile directory .PARAMETER OutFile Write metrics to a specific file path .PARAMETER Install Create a scheduled task for automatic collection .PARAMETER Listen Start HTTP listener on specified address:port .PARAMETER Interval Collection interval in seconds for scheduled task (default: 300) .NOTES Author: Phil Connor Contact: contact@mylinux.work Website: https://mylinux.work License: MIT Version: 1.0 #> param( [switch]$TextFile, [string]$OutFile = "", [switch]$Install, [string]$Listen = "", [int]$Interval = 300 ) $ErrorActionPreference = "SilentlyContinue" $Version = "1.0" $TextfileDir = "C:\ProgramData\node_exporter" $MetricPrefix = "windows_laps" # ============================================================================ # HELPER FUNCTIONS # ============================================================================ function Get-PrometheusEscape { param([string]$Value) $Value -replace '\\', '\\' -replace '"', '\"' -replace "`n", '' } function Write-MetricHeader { param([string]$Name, [string]$Help, [string]$Type) "# HELP $Name $Help" "# TYPE $Name $Type" } # ============================================================================ # METRIC GENERATION # ============================================================================ function Get-LapsMetrics { $startTime = Get-Date $metrics = [System.Collections.ArrayList]::new() # Check if LAPS module is available $lapsAvailable = $false if (Get-Module -ListAvailable -Name "LAPS" -ErrorAction SilentlyContinue) { Import-Module LAPS -ErrorAction SilentlyContinue $lapsAvailable = $true } # Also check for Windows LAPS (new in Server 2022+) $windowsLaps = $false if (Get-Command "Get-LapsAADPassword" -ErrorAction SilentlyContinue) { $windowsLaps = $true $lapsAvailable = $true } $up = if ($lapsAvailable) { 1 } else { 0 } [void]$metrics.Add((Write-MetricHeader "${MetricPrefix}_up" "LAPS exporter status (1=up, 0=down)" "gauge")) [void]$metrics.Add("${MetricPrefix}_up $up") [void]$metrics.Add("") [void]$metrics.Add((Write-MetricHeader "${MetricPrefix}_exporter_info" "Exporter version information" "gauge")) [void]$metrics.Add("${MetricPrefix}_exporter_info{version=`"$Version`"} 1") [void]$metrics.Add("") if ($up -eq 0) { $endTime = Get-Date $duration = [math]::Round(($endTime - $startTime).TotalSeconds, 2) [void]$metrics.Add((Write-MetricHeader "${MetricPrefix}_exporter_duration_seconds" "Script execution time" "gauge")) [void]$metrics.Add("${MetricPrefix}_exporter_duration_seconds $duration") return ($metrics -join "`n") } # ======================================================================== # COMPUTER ACCOUNT LAPS STATUS # ======================================================================== try { $computers = Get-ADComputer -Filter {Enabled -eq $true} -Properties ` "ms-Mcs-AdmPwdExpirationTime", "ms-Mcs-AdmPwd", "DistinguishedName", "OperatingSystem" ` -ErrorAction Stop $totalComputers = @($computers).Count $lapsManaged = 0 $lapsUnmanaged = 0 $passwordExpired = 0 $passwordExpiringSoon = 0 $now = Get-Date $soonThreshold = $now.AddDays(7) foreach ($computer in $computers) { $expTime = $computer."ms-Mcs-AdmPwdExpirationTime" if ($expTime -and $expTime -gt 0) { $lapsManaged++ $expDate = [DateTime]::FromFileTime($expTime) if ($expDate -lt $now) { $passwordExpired++ } elseif ($expDate -lt $soonThreshold) { $passwordExpiringSoon++ } } else { $lapsUnmanaged++ } } [void]$metrics.Add((Write-MetricHeader "${MetricPrefix}_computers_total" "Total enabled computer accounts" "gauge")) [void]$metrics.Add("${MetricPrefix}_computers_total $totalComputers") [void]$metrics.Add("") [void]$metrics.Add((Write-MetricHeader "${MetricPrefix}_computers_managed" "Computers with LAPS password set" "gauge")) [void]$metrics.Add("${MetricPrefix}_computers_managed $lapsManaged") [void]$metrics.Add("") [void]$metrics.Add((Write-MetricHeader "${MetricPrefix}_computers_unmanaged" "Computers without LAPS password" "gauge")) [void]$metrics.Add("${MetricPrefix}_computers_unmanaged $lapsUnmanaged") [void]$metrics.Add("") [void]$metrics.Add((Write-MetricHeader "${MetricPrefix}_passwords_expired" "Computers with expired LAPS passwords" "gauge")) [void]$metrics.Add("${MetricPrefix}_passwords_expired $passwordExpired") [void]$metrics.Add("") [void]$metrics.Add((Write-MetricHeader "${MetricPrefix}_passwords_expiring_soon" "Computers with LAPS passwords expiring within 7 days" "gauge")) [void]$metrics.Add("${MetricPrefix}_passwords_expiring_soon $passwordExpiringSoon") [void]$metrics.Add("") # Coverage percentage $coverage = if ($totalComputers -gt 0) { [math]::Round(($lapsManaged / $totalComputers) * 100, 1) } else { 0 } [void]$metrics.Add((Write-MetricHeader "${MetricPrefix}_coverage_percent" "LAPS coverage percentage" "gauge")) [void]$metrics.Add("${MetricPrefix}_coverage_percent $coverage") [void]$metrics.Add("") # ==================================================================== # PER-OU BREAKDOWN # ==================================================================== $ouStats = @{} foreach ($computer in $computers) { $dn = $computer.DistinguishedName $ouParts = ($dn -split ",") | Where-Object { $_ -match "^OU=" } $ou = if ($ouParts.Count -gt 0) { ($ouParts[0] -replace "^OU=", "") } else { "Default" } if (-not $ouStats.ContainsKey($ou)) { $ouStats[$ou] = @{ Total = 0; Managed = 0 } } $ouStats[$ou].Total++ $expTime = $computer."ms-Mcs-AdmPwdExpirationTime" if ($expTime -and $expTime -gt 0) { $ouStats[$ou].Managed++ } } [void]$metrics.Add((Write-MetricHeader "${MetricPrefix}_ou_computers_total" "Computers per OU" "gauge")) foreach ($ou in $ouStats.Keys) { $ouName = Get-PrometheusEscape $ou [void]$metrics.Add("${MetricPrefix}_ou_computers_total{ou=`"$ouName`"} $($ouStats[$ou].Total)") } [void]$metrics.Add("") [void]$metrics.Add((Write-MetricHeader "${MetricPrefix}_ou_computers_managed" "LAPS-managed computers per OU" "gauge")) foreach ($ou in $ouStats.Keys) { $ouName = Get-PrometheusEscape $ou [void]$metrics.Add("${MetricPrefix}_ou_computers_managed{ou=`"$ouName`"} $($ouStats[$ou].Managed)") } [void]$metrics.Add("") } catch { [void]$metrics.Add("# ERROR: Failed to query AD computers: $_") [void]$metrics.Add("") } # ======================================================================== # LAPS GPO STATUS # ======================================================================== try { $gpos = Get-GPO -All -ErrorAction Stop | Where-Object { $_.DisplayName -match "LAPS|Local Administrator Password" } $lapsGpos = @($gpos).Count [void]$metrics.Add((Write-MetricHeader "${MetricPrefix}_gpo_count" "LAPS-related GPOs" "gauge")) [void]$metrics.Add("${MetricPrefix}_gpo_count $lapsGpos") [void]$metrics.Add("") } catch { # GPO query may not be available on all systems } # ======================================================================== # EXPORTER RUNTIME # ======================================================================== $endTime = Get-Date $duration = [math]::Round(($endTime - $startTime).TotalSeconds, 2) $timestamp = [math]::Round((Get-Date -UFormat %s), 0) [void]$metrics.Add((Write-MetricHeader "${MetricPrefix}_exporter_duration_seconds" "Script execution time" "gauge")) [void]$metrics.Add("${MetricPrefix}_exporter_duration_seconds $duration") [void]$metrics.Add("") [void]$metrics.Add((Write-MetricHeader "${MetricPrefix}_exporter_last_run_timestamp" "Unix timestamp of last run" "gauge")) [void]$metrics.Add("${MetricPrefix}_exporter_last_run_timestamp $timestamp") return ($metrics -join "`n") } # ============================================================================ # OUTPUT MODES # ============================================================================ if ($Install) { $scriptPath = $MyInvocation.MyCommand.Path $action = New-ScheduledTaskAction -Execute "powershell.exe" ` -Argument "-NoProfile -ExecutionPolicy Bypass -File `"$scriptPath`" -TextFile" $trigger = New-ScheduledTaskTrigger -RepetitionInterval (New-TimeSpan -Seconds $Interval) -Once -At (Get-Date) $principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable Register-ScheduledTask -TaskName "LAPS Metrics Exporter" -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force Write-Host "Scheduled task 'LAPS Metrics Exporter' created (interval: ${Interval}s)" exit 0 } if ($Listen -ne "") { $port = $Listen -replace '.*:', '' if (-not $port) { $port = "9199" } $listener = [System.Net.HttpListener]::new() $listener.Prefixes.Add("http://+:$port/") $listener.Start() Write-Host "LAPS exporter listening on port $port..." while ($listener.IsListening) { $context = $listener.GetContext() $response = $context.Response $output = Get-LapsMetrics $buffer = [System.Text.Encoding]::UTF8.GetBytes($output) $response.ContentType = "text/plain; version=0.0.4" $response.ContentLength64 = $buffer.Length $response.OutputStream.Write($buffer, 0, $buffer.Length) $response.Close() } } elseif ($TextFile -or $OutFile -ne "") { $outputPath = if ($OutFile -ne "") { $OutFile } else { Join-Path $TextfileDir "laps.prom" } $outputDir = Split-Path $outputPath -Parent if (-not (Test-Path $outputDir)) { New-Item -ItemType Directory -Path $outputDir -Force | Out-Null } $tempFile = Join-Path $outputDir ".laps-metrics.tmp" $metricsOutput = Get-LapsMetrics $metricsOutput | Out-File -FilePath $tempFile -Encoding utf8 -NoNewline Move-Item -Path $tempFile -Destination $outputPath -Force Write-Host "Metrics written to $outputPath" } else { Get-LapsMetrics }