Sponsored by

THE PROBLEM

DHCP scope exhaustion is one of the most misdiagnosed outages in IT.

Nothing is down. The switches are fine, the firewall is fine, DNS resolves, the servers are all green. But people arriving on the 8:45 wave can't get on the network, and the ones who got in at 7:30 are working normally. So you spend forty minutes looking at the wireless controller before somebody thinks to open the DHCP console and finds the scope sitting at 100%.

It's almost always gradual. Headcount grows. Everyone carries a phone and a laptop now instead of just a laptop. Someone adds a guest VLAN that shares a scope it shouldn't. The scope crosses 80% in June, 90% in August, and nobody notices because DHCP has no alerting of its own and the console shows a number you only see when you go looking.

The fix is not complicated. It's just that nobody is watching. This week we build the watcher.

THE SCRIPT

Save the script below to:

C:\Scripts\DHCPScopeMonitor.ps1

powershell

# DHCP Scope Utilization Monitor
# Automate & Operate — automateandoperate.com

Import-Module DhcpServer

# Email settings — update these
$from = "[email protected]"
$to = "[email protected]"
$smtpServer = "your.smtp.server"
$smtpPort = 587
$username = "[email protected]"
$password = ConvertTo-SecureString "yourpassword" -AsPlainText -Force
$credential = New-Object System.Management.Automation.PSCredential($username, $password)

$logFile = "C:\Logs\DHCPScopeMonitor.log"
$reportFile = "C:\Logs\DHCPScopeReport.csv"
$trendFile = "C:\Logs\DHCPScopeTrend.csv"

# DHCP servers to monitor
# Leave empty to auto-discover authorized servers from AD
$dhcpServers = @()

# Utilization thresholds
$warningThreshold = 80
$criticalThreshold = 90

# Flag scopes with a lease duration longer than this (in hours)
# Long leases on high-churn networks are a common cause of false exhaustion
$longLeaseHours = 24

# Create log folder if missing
if (-not (Test-Path "C:\Logs")) {
    New-Item -ItemType Directory -Path "C:\Logs" -Force | Out-Null
}

$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$dateStamp = Get-Date -Format "yyyy-MM-dd"

$report = @()
$critical = @()
$warning = @()
$failoverIssues = @()
$longLeases = @()

# ---------- DISCOVER DHCP SERVERS ----------
if ($dhcpServers.Count -eq 0) {
    try {
        $dhcpServers = (Get-DhcpServerInDC -ErrorAction Stop).DnsName
        Add-Content -Path $logFile -Value "$timestamp — Auto-discovered $($dhcpServers.Count) authorized DHCP server(s)"
    } catch {
        Add-Content -Path $logFile -Value "$timestamp — DISCOVERY FAILED — $_ — set `$dhcpServers manually"
        $dhcpServers = @("localhost")
    }
}

foreach ($server in $dhcpServers) {

    # ---------- SCOPE UTILIZATION ----------
    try {
        $scopes = Get-DhcpServerv4Scope -ComputerName $server -ErrorAction Stop

        foreach ($scope in $scopes) {
            try {
                $stats = Get-DhcpServerv4ScopeStatistics -ComputerName $server -ScopeId $scope.ScopeId -ErrorAction Stop

                $percentUsed = [math]::Round($stats.PercentageInUse, 1)
                $leaseHours  = [math]::Round($scope.LeaseDuration.TotalHours, 1)

                $status = if ($scope.State -ne "Active") {
                    "INACTIVE"
                } elseif ($percentUsed -ge $criticalThreshold) {
                    "CRITICAL"
                } elseif ($percentUsed -ge $warningThreshold) {
                    "WARNING"
                } else {
                    "OK"
                }

                $entry = [PSCustomObject]@{
                    Date          = $dateStamp
                    Server        = $server
                    ScopeId       = $scope.ScopeId.ToString()
                    ScopeName     = $scope.Name
                    State         = $scope.State
                    StartRange    = $scope.StartRange.ToString()
                    EndRange      = $scope.EndRange.ToString()
                    TotalAddresses = $stats.AddressesFree + $stats.AddressesInUse
                    InUse         = $stats.AddressesInUse
                    Free          = $stats.AddressesFree
                    Reserved      = $stats.Reserved
                    PercentUsed   = $percentUsed
                    LeaseHours    = $leaseHours
                    Status        = $status
                }

                $report += $entry

                if ($status -eq "CRITICAL") { $critical += $entry }
                if ($status -eq "WARNING")  { $warning  += $entry }

                # Long lease on a scope that's filling up is worth calling out
                if ($leaseHours -gt $longLeaseHours -and $percentUsed -ge $warningThreshold) {
                    $longLeases += $entry
                }

                Add-Content -Path $logFile -Value "$timestamp — $server — $($scope.ScopeId) '$($scope.Name)' — $percentUsed% used ($($stats.AddressesInUse)/$($stats.AddressesFree + $stats.AddressesInUse)) — lease ${leaseHours}h — $status"

            } catch {
                Add-Content -Path $logFile -Value "$timestamp — $server — $($scope.ScopeId) — STATS ERROR: $_"
            }
        }

    } catch {
        $report += [PSCustomObject]@{
            Date          = $dateStamp
            Server        = $server
            ScopeId       = "UNREACHABLE"
            ScopeName     = "N/A"
            State         = "N/A"
            StartRange    = "N/A"
            EndRange      = "N/A"
            TotalAddresses = 0
            InUse         = 0
            Free          = 0
            Reserved      = 0
            PercentUsed   = 0
            LeaseHours    = 0
            Status        = "ERROR"
        }
        Add-Content -Path $logFile -Value "$timestamp — $server — SERVER ERROR: $_"
    }

    # ---------- FAILOVER HEALTH ----------
    try {
        $failovers = Get-DhcpServerv4Failover -ComputerName $server -ErrorAction SilentlyContinue

        foreach ($fo in $failovers) {
            if ($fo.State -ne "Normal") {
                $failoverIssues += [PSCustomObject]@{
                    Server        = $server
                    Relationship  = $fo.Name
                    PartnerServer = $fo.PartnerServer
                    Mode          = $fo.Mode
                    State         = $fo.State
                    ScopeCount    = ($fo.ScopeId | Measure-Object).Count
                }
                Add-Content -Path $logFile -Value "$timestamp — $server — FAILOVER '$($fo.Name)' state is $($fo.State) (partner: $($fo.PartnerServer))"
            }
        }
    } catch {
        Add-Content -Path $logFile -Value "$timestamp — $server — Failover check skipped: $_"
    }
}

# ---------- EXPORT CURRENT SNAPSHOT ----------
$report | Sort-Object PercentUsed -Descending | Export-Csv -Path $reportFile -NoTypeInformation

# ---------- APPEND TO TREND HISTORY ----------
$activeScopes = $report | Where-Object { $_.ScopeId -ne "UNREACHABLE" }

if ($activeScopes.Count -gt 0) {
    if (Test-Path $trendFile) {
        $activeScopes | Export-Csv -Path $trendFile -NoTypeInformation -Append
    } else {
        $activeScopes | Export-Csv -Path $trendFile -NoTypeInformation
    }
}

# ---------- CALCULATE GROWTH TREND ----------
$trendLines = @()

if (Test-Path $trendFile) {
    try {
        $history = Import-Csv -Path $trendFile
        $sevenDaysAgo = (Get-Date).AddDays(-7).ToString("yyyy-MM-dd")

        foreach ($entry in ($warning + $critical)) {
            $past = $history | Where-Object {
                $_.Server -eq $entry.Server -and
                $_.ScopeId -eq $entry.ScopeId -and
                $_.Date -le $sevenDaysAgo
            } | Sort-Object Date -Descending | Select-Object -First 1

            if ($past) {
                $growth = [math]::Round($entry.PercentUsed - [double]$past.PercentUsed, 1)

                if ($growth -gt 0) {
                    $pointsRemaining = 100 - $entry.PercentUsed
                    $weeksToFull = [math]::Round($pointsRemaining / $growth, 1)
                    $trendLines += "$($entry.ScopeName) ($($entry.ScopeId)) — grew $growth points in 7 days — full in approx $weeksToFull week(s)"
                } else {
                    $trendLines += "$($entry.ScopeName) ($($entry.ScopeId)) — flat or declining over 7 days"
                }
            }
        }
    } catch {
        Add-Content -Path $logFile -Value "$timestamp — Trend calculation skipped: $_"
    }
}

# ---------- BUILD EMAIL ----------
$emailBody  = "DHCP Scope Utilization Report — $timestamp`n"
$emailBody += "Servers checked: $($dhcpServers -join ', ')`n"
$emailBody += "Scopes found: $($report.Count)`n"
$emailBody += "Warning threshold: $warningThreshold%  |  Critical threshold: $criticalThreshold%`n`n"
$emailBody += "CRITICAL: $($critical.Count)  |  WARNING: $($warning.Count)  |  Failover issues: $($failoverIssues.Count)`n`n"

if ($critical.Count -gt 0) {
    $emailBody += "CRITICAL — ACT NOW:`n"
    $emailBody += "=" * 60 + "`n"
    foreach ($c in $critical) {
        $emailBody += "Scope:     $($c.ScopeName) ($($c.ScopeId))`n"
        $emailBody += "Server:    $($c.Server)`n"
        $emailBody += "Used:      $($c.PercentUsed)%  ($($c.InUse) in use / $($c.Free) free)`n"
        $emailBody += "Range:     $($c.StartRange) - $($c.EndRange)`n"
        $emailBody += "Lease:     $($c.LeaseHours) hours`n"
        $emailBody += "-" * 40 + "`n"
    }
    $emailBody += "`n"
}

if ($warning.Count -gt 0) {
    $emailBody += "WARNING — PLAN FOR THIS:`n"
    $emailBody += "=" * 60 + "`n"
    foreach ($w in $warning) {
        $emailBody += "$($w.Server) | $($w.ScopeName) ($($w.ScopeId)) | $($w.PercentUsed)% | $($w.Free) free | lease $($w.LeaseHours)h`n"
    }
    $emailBody += "`n"
}

if ($trendLines.Count -gt 0) {
    $emailBody += "7-DAY GROWTH TREND:`n"
    $emailBody += "=" * 60 + "`n"
    $trendLines | ForEach-Object { $emailBody += "$_`n" }
    $emailBody += "`n"
}

if ($longLeases.Count -gt 0) {
    $emailBody += "LONG LEASES ON BUSY SCOPES — CONSIDER SHORTENING:`n"
    $emailBody += "=" * 60 + "`n"
    foreach ($l in $longLeases) {
        $emailBody += "$($l.ScopeName) ($($l.ScopeId)) — $($l.LeaseHours) hour lease at $($l.PercentUsed)% used`n"
    }
    $emailBody += "`n"
}

if ($failoverIssues.Count -gt 0) {
    $emailBody += "DHCP FAILOVER NOT IN NORMAL STATE:`n"
    $emailBody += "=" * 60 + "`n"
    foreach ($f in $failoverIssues) {
        $emailBody += "Server:       $($f.Server)`n"
        $emailBody += "Relationship: $($f.Relationship)`n"
        $emailBody += "Partner:      $($f.PartnerServer)`n"
        $emailBody += "Mode:         $($f.Mode)`n"
        $emailBody += "State:        $($f.State)`n"
        $emailBody += "-" * 40 + "`n"
    }
    $emailBody += "`n"
}

if ($critical.Count -eq 0 -and $warning.Count -eq 0 -and $failoverIssues.Count -eq 0) {
    $emailBody += "All scopes below $warningThreshold% and failover healthy.`n`n"
}

$emailBody += "ALL SCOPES:`n"
$emailBody += "=" * 60 + "`n"
foreach ($r in ($report | Sort-Object PercentUsed -Descending)) {
    $emailBody += "[$($r.Status)] $($r.PercentUsed)% — $($r.ScopeName) ($($r.ScopeId)) — $($r.Free) free`n"
}

$emailBody += "`nFull report: $reportFile`n"
$emailBody += "Trend history: $trendFile`n"
$emailBody += "`nTo shorten a lease duration:`n"
$emailBody += "Set-DhcpServerv4Scope -ComputerName SERVER -ScopeId X.X.X.0 -LeaseDuration 08:00:00`n`n"
$emailBody += "Automate & Operate — automateandoperate.com"

$subject = if ($critical.Count -gt 0) {
    "DHCP CRITICAL: $($critical.Count) scope(s) above $criticalThreshold% utilization"
} elseif ($failoverIssues.Count -gt 0) {
    "DHCP ALERT: $($failoverIssues.Count) failover relationship(s) not in Normal state"
} elseif ($warning.Count -gt 0) {
    "DHCP Warning — $($warning.Count) scope(s) above $warningThreshold%"
} else {
    "DHCP Scope Report — All scopes healthy"
}

Send-MailMessage `
    -From $from -To $to `
    -Subject $subject `
    -Body $emailBody `
    -SmtpServer $smtpServer -Port $smtpPort `
    -UseSsl -Credential $credential

Update these lines:

  • $from / $to / $username / $password — your email details

  • $smtpServer — your SMTP server (e.g. smtp.office365.com)

  • $dhcpServers — leave empty to auto-discover from AD, or list them explicitly

  • $warningThreshold / $criticalThreshold — 80 and 90 are sensible defaults

Requirements: Run on a DHCP server, or a machine with RSAT DHCP tools installed. The account needs DHCP read rights — DHCP Users group is enough for reading, DHCP Administrators if you want it to make changes later.

To install the module on a management box:

powershell

Add-WindowsCapability -Online -Name "Rsat.DHCP.Tools~~~~0.0.1.0"

THE TREND FILE IS WHY THIS BEATS THE CONSOLE

You can see current utilization in the DHCP console any time you want. What you can't see is the direction of travel.

The script appends every run to DHCPScopeTrend.csv and compares against the reading from seven days ago. For any scope already in warning or critical, it works out how many percentage points it gained that week and projects roughly how long until it's full.

"Floor 3 Wireless is at 84%" is mildly interesting. "Floor 3 Wireless grew 6 points in 7 days — full in approx 2.7 weeks" is a task with a deadline attached.

The projection is deliberately crude — straight-line extrapolation with no seasonality. It's not trying to be clever. It just converts a static number into a countdown, which is what makes people act.

The first week produces no trend data because there's no history to compare against. From day eight onward it populates.

THE LONG LEASE SECTION

This is the one that fixes problems rather than just reporting them.

A lot of "exhausted" scopes aren't actually out of addresses — they're full of leases held by devices that left the building hours ago. Default lease duration on many scopes is 8 days, which is fine for a scope full of desktops that never move, and terrible for a guest wireless VLAN where devices appear for forty minutes and vanish.

The script flags any scope that's both above the warning threshold and carrying a lease longer than 24 hours. Those are your candidates for a quick win.

Shortening the lease on a high-churn scope:

powershell

Set-DhcpServerv4Scope -ComputerName "DHCP01" -ScopeId 10.20.30.0 -LeaseDuration 08:00:00

Eight hours suits a typical office day. Two to four hours suits guest wireless. Existing leases don't shorten retroactively — they'll renew at the new duration, so the effect builds over a lease cycle rather than appearing immediately.

Don't do this on a scope full of servers or printers. Short leases on static-ish infrastructure generate pointless renewal traffic and make DHCP a harder dependency than it needs to be. Reservations are the right answer there.

THE FAILOVER CHECK

If you run DHCP failover — and you should — the script reports any relationship not sitting in Normal state.

A failover relationship stuck in CommunicationInterrupted or PartnerDown isn't an outage. Everything keeps working, which is exactly the point of failover. But you're now running without redundancy, and if you don't know that, you find out when the surviving server also has a bad day.

This silently broken state can persist for months. It's the kind of thing that only surfaces during a DR test, if you run one.

SETTING UP TASK SCHEDULER

  1. Press Windows key → type Task Scheduler → open it

  2. Click "Create Basic Task" → name it DHCP Scope Monitor

  3. Trigger: Daily → 9:00am

  4. Action: Start a program

  5. Program: powershell.exe

  6. Arguments:

-ExecutionPolicy Bypass -File "C:\Scripts\DHCPScopeMonitor.ps1"
  1. Click Next → Finish

Under Properties → General, check "Run with highest privileges" and use an account with DHCP read rights.

9:00am is deliberate. Run this at 3am and every scope looks healthy because half your users are at home with expired leases. You want the reading taken when the building is full — that's the number that matters. If you're on a hybrid schedule, pick your busiest day rather than a random Tuesday.

Daily keeps the trend data meaningful. Weekly runs give you a projection built on two data points, which isn't worth much.

TEST IT

Run it on a DHCP server as administrator:

powershell

powershell.exe -ExecutionPolicy Bypass -File "C:\Scripts\DHCPScopeMonitor.ps1"

Open C:\Logs\DHCPScopeReport.csv and sort by PercentUsed descending. That top row is the scope you should be thinking about.

To check what the script sees before running the full thing:

powershell

Get-DhcpServerInDC
Get-DhcpServerv4Scope -ComputerName "DHCP01" | Format-Table ScopeId, Name, State, LeaseDuration
Get-DhcpServerv4ScopeStatistics -ComputerName "DHCP01" | Format-Table ScopeId, AddressesInUse, AddressesFree, PercentageInUse

To force a warning while testing, temporarily set $warningThreshold = 1 — every active scope will report and you'll confirm the email formatting works.

WHEN A SCOPE IS GENUINELY FULL

Shortening leases buys time. It doesn't add addresses. If a /24 is legitimately carrying 240 devices, you have three real options:

Extend the scope range if there's unused space in the subnet and nothing static is sitting in the way. Check for static assignments and reservations before you widen the range — overlapping a static server IP into a DHCP pool is a bad afternoon.

Superscope a second range onto the same VLAN. Works, but it's a band-aid and it complicates the routing story.

Re-subnet properly — split the VLAN, move to a /23. This is the right answer and the one nobody wants to do, because it's a change window and a router config.

The value of the trend report is that it gives you enough lead time to do option three on a planned Saturday rather than option one in a panic on a Monday.

WHY THIS MATTERS

DHCP is infrastructure that works perfectly until the exact moment it doesn't, and it has no native alerting whatsoever. There's no event log entry that says "you're running out of addresses" — there's just a scope quietly climbing while nobody looks at it.

The outage it causes is also uniquely annoying to diagnose because it's partial and time-dependent. Half the users are fine. The problem appears at a specific time of day and disappears by lunch as leases free up. It looks like wireless. It looks like a switch. It looks like anything except what it is.

Twelve lines of email once a day removes that entire category of incident from your life.

THIS WEEK'S ACTION

Run it against your DHCP servers today and note the top three scopes by utilization. Schedule it daily at 9am. Check back in a week — that's when the trend column populates, and that's when you'll know whether anything needs planning before the end of the quarter.

Reply to this email if you hit any issues — I read every reply.

Automate & Operate — automateandoperate.com

How 2M+ Professionals Stay Ahead on AI

AI is moving fast and most people are falling behind. 

The Rundown AI keeps you ahead of the curve. 

It's a free AI newsletter that keeps you up-to-date on the latest AI news, and teaches you how to apply it in just 5 minutes a day.

Plus, complete the quiz after signing up and they’ll recommend the best AI tools, guides, and courses — tailored to your needs.