<# .SYNOPSIS Windows DHCP Server Prometheus Exporter .DESCRIPTION Prometheus exporter for Windows DHCP Server - scope utilization, active leases, free addresses, reservations, failover status, DHCP statistics, and lease expiration tracking via PowerShell. Uses the built-in DhcpServer module. .PARAMETER Mode Output mode: 'stdout' (default), 'textfile', or 'http' .PARAMETER Port HTTP port for http mode (default: 9534) .PARAMETER TextfileDir Directory for textfile collector output (default: C:\ProgramData\node_exporter) .PARAMETER InstallScheduledTask Switch to create a scheduled task for automatic execution .PARAMETER TaskIntervalMinutes Interval in minutes for the scheduled task (default: 2) .NOTES Author: Phil Connor Contact: contact@mylinux.work Website: https://mylinux.work License: MIT Version: 1.0 Metrics Exported: Core: - windows_dhcp_up - windows_dhcp_exporter_info{version} Scope: - windows_dhcp_scope_pool_total{scope_id,name} - windows_dhcp_scope_pool_in_use{scope_id,name} - windows_dhcp_scope_pool_free{scope_id,name} - windows_dhcp_scope_pool_reserved{scope_id,name} - windows_dhcp_scope_pool_utilization{scope_id,name} - windows_dhcp_scope_pool_pending{scope_id,name} - windows_dhcp_scope_state{scope_id,name,state} - windows_dhcp_scope_leases_expiring{scope_id,name,within} - windows_dhcp_scope_lease_longest_seconds{scope_id,name} - windows_dhcp_scope_lease_shortest_seconds{scope_id,name} Failover: - windows_dhcp_failover_state{name,partner,mode,state} - windows_dhcp_failover_scope_count{name} Statistics: - windows_dhcp_discovers_total - windows_dhcp_offers_total - windows_dhcp_requests_total - windows_dhcp_acks_total - windows_dhcp_naks_total - windows_dhcp_declines_total - windows_dhcp_releases_total Totals: - windows_dhcp_scopes_total - windows_dhcp_leases_active_total - windows_dhcp_exporter_duration_seconds - windows_dhcp_exporter_last_run_timestamp #> param( [ValidateSet('stdout','textfile','http')] [string]$Mode = 'stdout', [int]$Port = 9534, [string]$TextfileDir = 'C:\ProgramData\node_exporter', [switch]$InstallScheduledTask, [int]$TaskIntervalMinutes = 2 ) $ErrorActionPreference = 'SilentlyContinue' $Version = '1.0' # ============================================================================ # SCHEDULED TASK INSTALLER # ============================================================================ if ($InstallScheduledTask) { $scriptPath = $MyInvocation.MyCommand.Path $action = New-ScheduledTaskAction -Execute 'powershell.exe' ` -Argument "-NoProfile -ExecutionPolicy Bypass -File `"$scriptPath`" -Mode textfile" $trigger = New-ScheduledTaskTrigger -RepetitionInterval (New-TimeSpan -Minutes $TaskIntervalMinutes) ` -Once -At (Get-Date) $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries ` -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Minutes 5) Register-ScheduledTask -TaskName 'WindowsDhcpExporter' -Action $action -Trigger $trigger ` -Settings $settings -RunLevel Highest -User 'SYSTEM' -Force Write-Host 'Scheduled task "WindowsDhcpExporter" installed successfully.' exit 0 } # ============================================================================ # HELPER FUNCTIONS # ============================================================================ function Get-PrometheusEscape { param([string]$Value) $Value -replace '\\', '\\\\' -replace '"', '\"' -replace "`n", '\n' } function Write-MetricHeader { param([string]$Name, [string]$Type, [string]$Help) "# HELP $Name $Help" "# TYPE $Name $Type" } # ============================================================================ # METRIC COLLECTION # ============================================================================ function Get-DhcpMetrics { $startTime = Get-Date $metrics = [System.Collections.Generic.List[string]]::new() # --- Exporter status --- $metrics.AddRange([string[]](Write-MetricHeader 'windows_dhcp_up' 'gauge' 'Exporter status (1=up, 0=down)')) try { Import-Module DhcpServer -ErrorAction Stop $metrics.Add('windows_dhcp_up 1') } catch { $metrics.Add('windows_dhcp_up 0') return ($metrics -join "`n") } $metrics.AddRange([string[]](Write-MetricHeader 'windows_dhcp_exporter_info' 'gauge' 'Exporter version information')) $metrics.Add("windows_dhcp_exporter_info{version=`"$Version`"} 1") # --- Scope metrics --- try { $scopes = Get-DhcpServerv4Scope -ErrorAction Stop $totalScopes = ($scopes | Measure-Object).Count $totalActive = 0 $metrics.AddRange([string[]](Write-MetricHeader 'windows_dhcp_scope_pool_total' 'gauge' 'Total addresses in the DHCP scope')) $metrics.AddRange([string[]](Write-MetricHeader 'windows_dhcp_scope_pool_in_use' 'gauge' 'Currently leased addresses')) $metrics.AddRange([string[]](Write-MetricHeader 'windows_dhcp_scope_pool_free' 'gauge' 'Available addresses in the scope')) $metrics.AddRange([string[]](Write-MetricHeader 'windows_dhcp_scope_pool_reserved' 'gauge' 'Number of reservations in the scope')) $metrics.AddRange([string[]](Write-MetricHeader 'windows_dhcp_scope_pool_utilization' 'gauge' 'Scope utilization percentage')) $metrics.AddRange([string[]](Write-MetricHeader 'windows_dhcp_scope_pool_pending' 'gauge' 'Pending offers')) $metrics.AddRange([string[]](Write-MetricHeader 'windows_dhcp_scope_state' 'gauge' 'Scope state (1=current state)')) $metrics.AddRange([string[]](Write-MetricHeader 'windows_dhcp_scope_leases_expiring' 'gauge' 'Leases expiring within threshold')) $metrics.AddRange([string[]](Write-MetricHeader 'windows_dhcp_scope_lease_longest_seconds' 'gauge' 'Remaining time on the longest lease')) $metrics.AddRange([string[]](Write-MetricHeader 'windows_dhcp_scope_lease_shortest_seconds' 'gauge' 'Remaining time on the shortest lease')) foreach ($scope in $scopes) { $scopeId = $scope.ScopeId.ToString() $scopeName = Get-PrometheusEscape $scope.Name try { $stats = Get-DhcpServerv4ScopeStatistics -ScopeId $scope.ScopeId -ErrorAction Stop $total = $stats.AddressesAvailable + $stats.AddressesInUse $inUse = $stats.AddressesInUse $free = $stats.AddressesAvailable $pending = $stats.PendingOffers $reserved = (Get-DhcpServerv4Reservation -ScopeId $scope.ScopeId -ErrorAction SilentlyContinue | Measure-Object).Count $util = if ($total -gt 0) { [math]::Round(($inUse / $total) * 100, 2) } else { 0 } $totalActive += $inUse $metrics.Add("windows_dhcp_scope_pool_total{scope_id=`"$scopeId`",name=`"$scopeName`"} $total") $metrics.Add("windows_dhcp_scope_pool_in_use{scope_id=`"$scopeId`",name=`"$scopeName`"} $inUse") $metrics.Add("windows_dhcp_scope_pool_free{scope_id=`"$scopeId`",name=`"$scopeName`"} $free") $metrics.Add("windows_dhcp_scope_pool_reserved{scope_id=`"$scopeId`",name=`"$scopeName`"} $reserved") $metrics.Add("windows_dhcp_scope_pool_utilization{scope_id=`"$scopeId`",name=`"$scopeName`"} $util") $metrics.Add("windows_dhcp_scope_pool_pending{scope_id=`"$scopeId`",name=`"$scopeName`"} $pending") } catch { # Skip scope stats if unavailable } # Scope state $stateStr = $scope.State.ToString() $metrics.Add("windows_dhcp_scope_state{scope_id=`"$scopeId`",name=`"$scopeName`",state=`"$stateStr`"} 1") # Lease expiration tracking try { $leases = Get-DhcpServerv4Lease -ScopeId $scope.ScopeId -ErrorAction Stop | Where-Object { $_.AddressState -match 'Active' -and $_.LeaseExpiryTime } $now = Get-Date $exp1h = 0; $exp4h = 0; $exp24h = 0 $longest = 0; $shortest = [int]::MaxValue foreach ($lease in $leases) { $remaining = ($lease.LeaseExpiryTime - $now).TotalSeconds if ($remaining -gt 0) { if ($remaining -le 3600) { $exp1h++ } if ($remaining -le 14400) { $exp4h++ } if ($remaining -le 86400) { $exp24h++ } if ($remaining -gt $longest) { $longest = [math]::Round($remaining) } if ($remaining -lt $shortest) { $shortest = [math]::Round($remaining) } } } if ($shortest -eq [int]::MaxValue) { $shortest = 0 } $metrics.Add("windows_dhcp_scope_leases_expiring{scope_id=`"$scopeId`",name=`"$scopeName`",within=`"1h`"} $exp1h") $metrics.Add("windows_dhcp_scope_leases_expiring{scope_id=`"$scopeId`",name=`"$scopeName`",within=`"4h`"} $exp4h") $metrics.Add("windows_dhcp_scope_leases_expiring{scope_id=`"$scopeId`",name=`"$scopeName`",within=`"24h`"} $exp24h") $metrics.Add("windows_dhcp_scope_lease_longest_seconds{scope_id=`"$scopeId`",name=`"$scopeName`"} $longest") $metrics.Add("windows_dhcp_scope_lease_shortest_seconds{scope_id=`"$scopeId`",name=`"$scopeName`"} $shortest") } catch { # Skip lease details if unavailable } } # Scope totals $metrics.AddRange([string[]](Write-MetricHeader 'windows_dhcp_scopes_total' 'gauge' 'Total number of configured scopes')) $metrics.Add("windows_dhcp_scopes_total $totalScopes") $metrics.AddRange([string[]](Write-MetricHeader 'windows_dhcp_leases_active_total' 'gauge' 'Total active leases across all scopes')) $metrics.Add("windows_dhcp_leases_active_total $totalActive") } catch { # DHCP scope enumeration failed } # --- Failover --- try { $failovers = Get-DhcpServerv4Failover -ErrorAction Stop $metrics.AddRange([string[]](Write-MetricHeader 'windows_dhcp_failover_state' 'gauge' 'Failover relationship state (1=Normal)')) $metrics.AddRange([string[]](Write-MetricHeader 'windows_dhcp_failover_scope_count' 'gauge' 'Number of scopes in the failover relationship')) foreach ($fo in $failovers) { $foName = Get-PrometheusEscape $fo.Name $partner = Get-PrometheusEscape $fo.PartnerServer $foMode = $fo.Mode.ToString() $foState = $fo.State.ToString() $stateVal = if ($foState -eq 'Normal') { 1 } else { 0 } $scopeCount = ($fo.ScopeId | Measure-Object).Count $metrics.Add("windows_dhcp_failover_state{name=`"$foName`",partner=`"$partner`",mode=`"$foMode`",state=`"$foState`"} $stateVal") $metrics.Add("windows_dhcp_failover_scope_count{name=`"$foName`"} $scopeCount") } } catch { # No failover configured - skip } # --- DHCP server statistics --- try { $stats = Get-DhcpServerv4Statistics -ErrorAction Stop $metrics.AddRange([string[]](Write-MetricHeader 'windows_dhcp_discovers_total' 'counter' 'Total DHCPDISCOVER packets received')) $metrics.Add("windows_dhcp_discovers_total $($stats.Discovers)") $metrics.AddRange([string[]](Write-MetricHeader 'windows_dhcp_offers_total' 'counter' 'Total DHCPOFFER packets sent')) $metrics.Add("windows_dhcp_offers_total $($stats.Offers)") $metrics.AddRange([string[]](Write-MetricHeader 'windows_dhcp_requests_total' 'counter' 'Total DHCPREQUEST packets received')) $metrics.Add("windows_dhcp_requests_total $($stats.Requests)") $metrics.AddRange([string[]](Write-MetricHeader 'windows_dhcp_acks_total' 'counter' 'Total DHCPACK packets sent')) $metrics.Add("windows_dhcp_acks_total $($stats.Acks)") $metrics.AddRange([string[]](Write-MetricHeader 'windows_dhcp_naks_total' 'counter' 'Total DHCPNAK packets sent')) $metrics.Add("windows_dhcp_naks_total $($stats.Naks)") $metrics.AddRange([string[]](Write-MetricHeader 'windows_dhcp_declines_total' 'counter' 'Total DHCPDECLINE packets received')) $metrics.Add("windows_dhcp_declines_total $($stats.Declines)") $metrics.AddRange([string[]](Write-MetricHeader 'windows_dhcp_releases_total' 'counter' 'Total DHCPRELEASE packets received')) $metrics.Add("windows_dhcp_releases_total $($stats.Releases)") } catch { # Statistics unavailable } # --- Duration and timestamp --- $duration = [math]::Round(((Get-Date) - $startTime).TotalSeconds, 2) $timestamp = [math]::Round((Get-Date -UFormat %s), 0) $metrics.AddRange([string[]](Write-MetricHeader 'windows_dhcp_exporter_duration_seconds' 'gauge' 'Script execution time')) $metrics.Add("windows_dhcp_exporter_duration_seconds $duration") $metrics.AddRange([string[]](Write-MetricHeader 'windows_dhcp_exporter_last_run_timestamp' 'gauge' 'Unix timestamp of last successful run')) $metrics.Add("windows_dhcp_exporter_last_run_timestamp $timestamp") return ($metrics -join "`n") } # ============================================================================ # OUTPUT # ============================================================================ switch ($Mode) { 'stdout' { Get-DhcpMetrics } 'textfile' { if (-not (Test-Path $TextfileDir)) { New-Item -ItemType Directory -Path $TextfileDir -Force | Out-Null } $tempFile = Join-Path $TextfileDir "windows-dhcp-metrics.tmp" $finalFile = Join-Path $TextfileDir "windows-dhcp-metrics.prom" Get-DhcpMetrics | Out-File -FilePath $tempFile -Encoding utf8 -NoNewline Move-Item -Path $tempFile -Destination $finalFile -Force Write-Host "Wrote metrics to $finalFile" } 'http' { $prefix = "http://+:$Port/metrics/" $listener = [System.Net.HttpListener]::new() $listener.Prefixes.Add($prefix) $listener.Start() Write-Host "Listening on port $Port..." try { while ($listener.IsListening) { $context = $listener.GetContext() $response = $context.Response $metricsOutput = Get-DhcpMetrics $buffer = [System.Text.Encoding]::UTF8.GetBytes($metricsOutput) $response.ContentType = 'text/plain; version=0.0.4; charset=utf-8' $response.ContentLength64 = $buffer.Length $response.OutputStream.Write($buffer, 0, $buffer.Length) $response.OutputStream.Close() } } finally { $listener.Stop() } } }