Quickly find all IIS servers on the network with PowerShell
Have you ever needed to audit thousands of servers, to find which ones are running IIS? If you have, you've probably discovered it's horribly slow using Get-Service
inside a foreach statment, just like I did. You will likely also find, that by attempting to filter out unresponsive servers, the number of errors may go down, but the time it takes to run becomes painful.
Firstly the painful way
$computers = Get-ADComputer -Filter {enabled -eq $true} -Properties Name | select @{Name="ComputerName";Expression={$_.Name}}
foreach($computer in $computers){
if((Test-Connection -ComputerName $computer -Quiet) -eq $true){
Get-Service -ComputerName $computer -ServiceName "W3SVC" -ErrorAction SilentlyContinue
}
}
While this works well enough for 5-10 machines, it quickly becomes unacceptable running against hundreds, let alone thousands of machines. I also don't like having to translate the name.
The path to joy
I knew the Test-Connection
calls were taking a long time, as of course, it has to wait for 4 responses from each machine. I went searching and found Test-ConnectionAsync on PowerShell gallery.
At first I attempted to simply replace the calls with to Test-Connection
with Test-ConnectionAsync
however I quickly realised that was a mistake, as it was still being called one at a time inside the foreach loop. So I changed it up abit.
Import-Module Test-ConnectionAsync
$computers = Get-ADComputer -Filter {enabled -eq $true} -Properties Name | select @{Name="ComputerName";Expression={$_.Name}} | Test-ConnectionAsync -Quiet -MaxConcurrent 250 | where {$_.Success -eq $true} | select -ExpandProperty ComputerName
foreach($computer in $computers){
Get-Service -ComputerName $computer -ServiceName "W3SVC" -ErrorAction SilentlyContinue
}
This significantly improved the time it took to get the list of machines, but it has a similar problem with Get-Service
in that it must wait for the response from each server, and some of them can be pretty slow.
Open source is awesome###
I looked back at the Test-ConnectionAsync
page and realised that the module source was linked, having a read through the file, I noticed exactly the same method could be applied to Get-Service
, but before doing that, I tweaked my copy of Test-ConnectionAsync
to remove the need for the annoying select @{Name="ComputerName";Expression={$_.Name}}
statement, by renaming the ComputerName attribute to Name (plus all references), and instead adding it as an alias. Unfortunately just adding Name as an alias is not sufficient, due to some annoying issue with the ActiveDirecory PowerShell commands.
[Parameter(Mandatory=$true, Position=0, ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true)]
[Alias('CN','IPAddress','__SERVER','Server','Destination','ComputerName')]
[ValidateNotNullOrEmpty()]
[System.String[]]
${Name},
This change allowed me to simplify the usage
Import-Module Test-ConnectionAsync
$computers = Get-ADComputer -Filter {enabled -eq $true} -Properties Name | Test-ConnectionAsync -Quiet -MaxConcurrent 250 | where {$_.Success -eq $true} | select -ExpandProperty Name
foreach($computer in $computers){
Get-Service -ComputerName $computer -ServiceName "W3SVC" -ErrorAction SilentlyContinue
}
Then I made a copy of Test-ConnectionAsync
and swapped out the necessary parts to call Get-Service
instead, and saved it in Find-WebServerAsync.psm1
function Find-WebServerAsync
{
<#
.Synopsis
Queries the list of servers in batches looking for the world wide web service 'w3svc'
.DESCRIPTION
Proxy function for Get-Service
.PARAMETER MaxConcurrent
Specifies the maximum number of Find-WebServerAsync commands to run at a time.
#>
[CmdletBinding(DefaultParameterSetName='Default')]
param(
[Parameter(Mandatory=$true, Position=0, ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true)]
[ValidateNotNullOrEmpty()]
[string[]] ${Name},
[ValidateRange(1, 60)]
[System.Int32]
${Delay},
[ValidateScript({$_ -ge 1})]
[System.UInt32]
$MaxConcurrent = 20,
[Parameter(ParameterSetName='Quiet')]
[Switch]
$Quiet
)
begin
{
if ($null -ne ${function:Get-CallerPreference})
{
Get-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState
}
$null = $PSBoundParameters.Remove('MaxConcurrent')
$null = $PSBoundParameters.Remove('Quiet')
$jobs = @{}
$i = -1
function ProcessCompletedJob
{
[CmdletBinding()]
param (
[Parameter(Mandatory = $true)]
[hashtable]
$Jobs,
[Parameter(Mandatory = $true)]
[int]
$Index,
[switch]
$Quiet
)
$quietStatus = New-Object psobject -Property @{Name = $Jobs[$Index].Target; Success = $false}
if ($Jobs[$Index].Job.HasMoreData)
{
foreach ($result in (Receive-Job $Jobs[$Index].Job))
{
if ($Quiet)
{
$quietStatus.Success = $result
break
}
else
{
Write-Output $result
}
}
}
if ($Quiet)
{
Write-Output $quietStatus
}
Remove-Job -Job $Jobs[$Index].Job -Force
$Jobs[$Index] = $null
} # function ProcessCompletedJob
} # begin
process
{
$null = $PSBoundParameters.Remove('Name')
foreach ($target in $Name)
{
while ($true)
{
if (++$i -eq $MaxConcurrent)
{
Start-Sleep -Milliseconds 100
$i = 0
}
if ($null -ne $jobs[$i] -and $jobs[$i].Job.JobStateInfo.State -ne [System.Management.Automation.JobState]::Running)
{
ProcessCompletedJob -Jobs $jobs -Index $i -Quiet:$Quiet
}
if ($null -eq $jobs[$i])
{
Write-Verbose "Job ${i}: Testing ${target}."
$job = Start-Job -ScriptBlock {(Get-Service -ComputerName $args[0] -ServiceName "W3SVC" -ErrorAction SilentlyContinue) -ne $null} -ArgumentList $target #@PSBoundParameters
$jobs[$i] = New-Object psobject -Property @{Target = $target; Job = $job}
break
}
}
}
}
end
{
while ($true)
{
$foundActive = $false
for ($i = 0; $i -lt $MaxConcurrent; $i++)
{
if ($null -ne $jobs[$i])
{
if ($jobs[$i].Job.JobStateInfo.State -ne [System.Management.Automation.JobState]::Running)
{
ProcessCompletedJob -Jobs $jobs -Index $i -Quiet:$Quiet
}
else
{
$foundActive = $true
}
}
}
if (-not $foundActive)
{
break
}
Start-Sleep -Milliseconds 100
}
}
} # function Find-WebServerAsync
That's more like it
Now with the two async modules, I can accomplish the same task much quicker, and with a nicer syntax.
Import-Module Test-ConnectionAsync
Import-Module Find-WebServerAsync
$webServers = Get-ADComputer -Filter {enabled -eq $true} -Properties Name | Test-ConnectionAsync -Quiet | where {$_.Success -eq $true} | Find-WebServerAsync -Quiet | where {$_.Success -eq $true}
In my environment, this went from taking 10-15 mins to run, to around 1 min 30.