FastPing.psm1

class FastPingResponse
{
    [String] $HostName
    [Nullable[Double]] $RoundtripAverage
    [Boolean] $Online
    [System.Net.NetworkInformation.IPStatus] $Status

    FastPingResponse(
        [String] $HostName,
        [Nullable[Double]] $RoundtripAverage,
        [Boolean] $Online,
        [System.Net.NetworkInformation.IPStatus] $Status
    )
    {
        $this.HostName = $HostName
        if ($null -ne $RoundtripAverage)
        {
            $this.RoundtripAverage = $RoundtripAverage
        }
        $this.Online = $Online
        $this.Status = $Status
    }
}

<#
    .SYNOPSIS
    Converts a Decimal IP address into a 32-bit unsigned integer.

    .DESCRIPTION
    ConvertToDecimalIP takes a decimal IP, uses a shift operation on each octet and returns a single UInt32 value.

    .INPUTS
    System.Net.IPAddress

    .EXAMPLE
    ConvertToDecimalIP 1.2.3.4

    Converts an IP address to an unsigned 32-bit integer value.

    .NOTES
    This code is copied from the Indented.Net.IP module (https://github.com/indented-automation/Indented.Net.IP).
    The copy is due to not wanting to take a dependency, and that module licensed with a permissive license.
    Thanks Chris Dent!
#>

function ConvertToDecimalIP
{
    [CmdletBinding()]
    [OutputType([UInt32])]
    param
    (
        # An IP Address to convert.
        [Parameter(Mandatory, Position = 1, ValueFromPipeline )]
        [IPAddress] $IPAddress
    )

    process
    {
        [UInt32]([IPAddress]::HostToNetworkOrder($IPAddress.Address) -shr 32 -band [UInt32]::MaxValue)
    }
}

<#
    .SYNOPSIS
    Converts IP address formats to a set a known styles.

    .DESCRIPTION
    ConvertToNetwork ensures consistent values are recorded from parameters which must handle differing addressing formats. This Cmdlet allows all other the other functions in this module to offload parameter handling.

    .NOTES
    Change log:
        05/03/2016 - Chris Dent - Refactored and simplified.
        14/01/2014 - Chris Dent - Created.

    This code is copied from the Indented.Net.IP module (https://github.com/indented-automation/Indented.Net.IP).
    The copy is due to not wanting to take a dependency, and that module licensed with a permissive license.
    Thanks Chris Dent!
#>

function ConvertToNetwork
{
    [CmdletBinding()]
    [OutputType('Indented.Net.IP.Network')]
    param
    (
        # Either a literal IP address, a network range expressed as CIDR notation, or an IP address and subnet mask in a string.
        [Parameter(Mandatory = $true, Position = 1)]
        [String] $IPAddress,

        # A subnet mask as an IP address.
        [Parameter(Position = 2)]
        [AllowNull()]
        [String] $SubnetMask
    )

    $validSubnetMaskValues = @(
        '0.0.0.0',
        '128.0.0.0',
        '192.0.0.0',
        '224.0.0.0',
        '240.0.0.0',
        '248.0.0.0',
        '252.0.0.0',
        '254.0.0.0',
        '255.0.0.0',
        '255.128.0.0',
        '255.192.0.0',
        '255.224.0.0',
        '255.240.0.0',
        '255.248.0.0',
        '255.252.0.0',
        '255.254.0.0',
        '255.255.0.0',
        '255.255.128.0',
        '255.255.192.0',
        '255.255.224.0',
        '255.255.240.0',
        '255.255.248.0',
        '255.255.252.0',
        '255.255.254.0',
        '255.255.255.0',
        '255.255.255.128',
        '255.255.255.192',
        '255.255.255.224',
        '255.255.255.240',
        '255.255.255.248',
        '255.255.255.252',
        '255.255.255.254',
        '255.255.255.255'
    )

    $network = [PSCustomObject]@{
        IPAddress  = $null
        SubnetMask = $null
        MaskLength = 0
        PSTypeName = 'Indented.Net.IP.Network'
    }

    # Override ToString
    $network | Add-Member ToString -MemberType ScriptMethod -Force -Value {
        '{0}/{1}' -f $this.IPAddress, $this.MaskLength
    }

    if (-not $psboundparameters.ContainsKey('SubnetMask') -or $SubnetMask -eq '')
    {
        $IPAddress, $SubnetMask = $IPAddress.Split([Char[]]'\/ ', [StringSplitOptions]::RemoveEmptyEntries)
    }

    # IPAddress
    while ($IPAddress.Split('.').Count -lt 4)
    {
        $IPAddress += '.0'
    }

    if ([IPAddress]::TryParse($IPAddress, [Ref]$null))
    {
        $network.IPAddress = [IPAddress]$IPAddress
    }
    else
    {
        $errorRecord = [System.Management.Automation.ErrorRecord]::new(
            [ArgumentException]'Invalid IP address.',
            'InvalidIPAddress',
            'InvalidArgument',
            $IPAddress
        )
        $pscmdlet.ThrowTerminatingError($errorRecord)
    }

    # SubnetMask
    if ($null -eq $SubnetMask -or $SubnetMask -eq '')
    {
        $network.SubnetMask = [IPAddress]$validSubnetMaskValues[32]
        $network.MaskLength = 32
    }
    else
    {
        $maskLength = 0
        if ([Int32]::TryParse($SubnetMask, [Ref]$maskLength))
        {
            if ($MaskLength -ge 0 -and $maskLength -le 32)
            {
                $network.SubnetMask = [IPAddress]$validSubnetMaskValues[$maskLength]
                $network.MaskLength = $maskLength
            }
            else
            {
                $errorRecord = [System.Management.Automation.ErrorRecord]::new(
                    [ArgumentException]'Mask length out of range (expecting 0 to 32).',
                    'InvalidMaskLength',
                    'InvalidArgument',
                    $SubnetMask
                )
                $pscmdlet.ThrowTerminatingError($errorRecord)
            }
        }
        else
        {
            while ($SubnetMask.Split('.').Count -lt 4)
            {
                $SubnetMask += '.0'
            }
            $maskLength = $validSubnetMaskValues.IndexOf($SubnetMask)

            if ($maskLength -ge 0)
            {
                $Network.SubnetMask = [IPAddress]$SubnetMask
                $Network.MaskLength = $maskLength
            }
            else
            {
                $errorRecord = [System.Management.Automation.ErrorRecord]::new(
                    [ArgumentException]'Invalid subnet mask.',
                    'InvalidSubnetMask',
                    'InvalidArgument',
                    $SubnetMask
                )
                $pscmdlet.ThrowTerminatingError($errorRecord)
            }
        }
    }

    $network
}

<#
    .SYNOPSIS
    Get a list of IP addresses within the specified network.

    .DESCRIPTION
    GetNetworkRange finds the network and broadcast address as decimal values then starts a counter between the two, returning IPAddress for each.

    .INPUTS
    System.String

    .EXAMPLE
    GetNetworkRange 192.168.0.0 255.255.255.0

    Returns all IP addresses in the range 192.168.0.0/24.

    .EXAMPLE
    GetNetworkRange 10.0.8.0/22

    Returns all IP addresses in the range 192.168.0.0 255.255.252.0.

    .NOTES
    This code is copied from the Indented.Net.IP module (https://github.com/indented-automation/Indented.Net.IP).
    The copy is due to not wanting to take a dependency, and that module licensed with a permissive license.
    Thanks Chris Dent!
#>

function GetNetworkRange
{
    [CmdletBinding(DefaultParameterSetName = 'FromIPAndMask')]
    [OutputType([IPAddress])]
    param (
        # Either a literal IP address, a network range expressed as CIDR notation, or an IP address and subnet mask in a string.
        [Parameter(Mandatory, Position = 1, ValueFromPipeline, ParameterSetName = 'FromIPAndMask')]
        [String] $IPAddress,

        # A subnet mask as an IP address.
        [Parameter(Position = 2, ParameterSetName = 'FromIPAndMask')]
        [String] $SubnetMask,

        # Include the network and broadcast addresses when generating a network address range.
        [Parameter(ParameterSetName = 'FromIPAndMask')]
        [Switch] $IncludeNetworkAndBroadcast,

        # The start address of a range.
        [Parameter(Mandatory, ParameterSetName = 'FromStartAndEnd')]
        [IPAddress] $StartIPAddress,

        # The end address of a range.
        [Parameter(Mandatory, ParameterSetName = 'FromStartAndEnd')]
        [IPAddress] $EndIPAddress
    )

    process
    {
        if ($pscmdlet.ParameterSetName -eq 'FromIPAndMask')
        {
            try
            {
                $null = $psboundparameters.Remove('IncludeNetworkAndBroadcast')
                $network = ConvertToNetwork @psboundparameters
            }
            catch
            {
                $pscmdlet.ThrowTerminatingError($_)
            }

            $decimalIP = ConvertToDecimalIP -IPAddress $network.IPAddress
            $decimalMask = ConvertToDecimalIP -IPAddress $network.SubnetMask

            $startDecimal = $decimalIP -band $decimalMask
            $endDecimal = $decimalIP -bor (-bnot $decimalMask -band [UInt32]::MaxValue)

            if (-not $IncludeNetworkAndBroadcast)
            {
                $startDecimal++
                $endDecimal--
            }
        }
        else
        {
            $startDecimal = ConvertToDecimalIP -IPAddress $StartIPAddress
            $endDecimal = ConvertToDecimalIP -IPAddress $EndIPAddress
        }

        for ($i = $startDecimal; $i -le $endDecimal; $i++)
        {
            [IPAddress]([IPAddress]::NetworkToHostOrder([Int64]$i) -shr 32 -band [UInt32]::MaxValue)
        }
    }
}

<#
    .SYNOPSIS
    Performs a series of asynchronous pings against a set of target hosts.

    .DESCRIPTION
    This function uses System.Net.Networkinformation.Ping object to perform
    a series of asynchronous pings against a set of target hosts.

    .PARAMETER HostName
    String array of target hosts.

    .PARAMETER Count
    Number of echo requests to send. Aliased with 'n', like ping.exe.

    .PARAMETER Continuous
    Enables continuous pings against the target hosts. Stop with CTRL+C. Aliases with 't', like ping.exe.

    .PARAMETER Timeout
    Timeout in milliseconds to wait for each reply. Defaults to 2 seconds (5000). Aliased with 'w', like ping.exe.

    Per MSDN Documentation, "When specifying very small numbers for timeout, the Ping reply can be received even if timeout milliseconds have elapsed." (https://msdn.microsoft.com/en-us/library/ms144955.aspx).

    .PARAMETER Interval
    Number of milliseconds between echo requests.

    .PARAMETER RoundtripAveragePingCount
    Number of echo requests to send for calculating the Roundtrip average. Defaults to 4.

    .EXAMPLE
    Invoke-FastPing -HostName 'andrewpearce.io'

    HostName RoundtripAverage Online
    -------- ---------------- ------
    andrewpearce.io 22 True

    .EXAMPLE
    Invoke-FastPing -HostName 'andrewpearce.io','doesnotexist.andrewpearce.io'

    HostName RoundtripAverage Online
    -------- ---------------- ------
    doesnotexist.andrewpearce.io False
    andrewpearce.io 22 True

    .EXAMPLE
    Invoke-FastPing -HostName 'andrewpearce.io' -Count 5

    This example pings the host 'andrewpearce.io' five times.

    .EXAMPLE
    fp andrewpearce.io -n 5

    This example pings the host 'andrewpearce.io' five times using syntax similar to ping.exe.

    .EXAMPLE
    Invoke-FastPing -HostName 'microsoft.com' -Timeout 500

    This example pings the host 'microsoft.com' with a 500 millisecond timeout.

    .EXAMPLE
    fp microsoft.com -w 500

    This example pings the host 'microsoft.com' with a 500 millisecond timeout using syntax similar to ping.exe.

    .EXAMPLE
    fp andrewpearce.io -Continuous

    This example pings the host 'andrewpearce.io' continuously until CTRL+C is used.

    .EXAMPLE
    fp andrewpearce.io -t

    This example pings the host 'andrewpearce.io' continuously until CTRL+C is used.
#>

function Invoke-FastPing
{
    [CmdletBinding(DefaultParameterSetName='Count')]
    [Alias('FastPing', 'fping', 'fp')]
    param
    (
        [Parameter(Position = 0, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [ValidateNotNullOrEmpty()]
        [Alias('Computer', 'ComputerName', 'Host')]
        [String[]] $HostName,

        [Parameter(ParameterSetName='Count')]
        [ValidateRange(1, [Int]::MaxValue)]
        [Alias('N')]
        [Int] $Count = 1,

        [Parameter(ParameterSetName='Continuous')]
        [Alias('T')]
        [Switch] $Continuous,

        [ValidateRange(1, [Int]::MaxValue)]
        [Alias('W')]
        [Int] $Timeout = 5000,

        [ValidateRange(1, [Int]::MaxValue)]
        [Int] $Interval = 1000,

        [ValidateRange(1, [Int]::MaxValue)]
        [Int] $RoundtripAveragePingCount = 4
    )

    begin
    {
        # The time used for the ping async wait() method
        $asyncWaitMilliseconds = 500

        # Used to control the Count of echo requests
        $loopCounter = 0

        # Used to control the Interval between echo requests
        $loopTimer = [System.Diagnostics.Stopwatch]::new()
    }

    process
    {
        try
        {
            while ($true)
            {
                $loopTimer.Restart()

                # Objects to hold items as we process pings
                $queue = [System.Collections.Queue]::new()
                $pingHash = @{}

                # Start an asynchronous ping against each computer
                foreach ($hn in $HostName)
                {
                    if ($pingHash.Keys -notcontains $hn)
                    {
                        $pingHash.Add($hn, [System.Collections.ArrayList]::new())
                    }

                    for ($i = 0; $i -lt $RoundtripAveragePingCount; $i++)
                    {
                        $ping = [System.Net.Networkinformation.Ping]::new()
                        $object = @{
                            Host  = $hn
                            Ping  = $ping
                            Async = $ping.SendPingAsync($hn, $Timeout)
                        }
                        $queue.Enqueue($object)
                    }
                }

                # Process the asynchronous pings
                while ($queue.Count -gt 0)
                {
                    $object = $queue.Dequeue()

                    try
                    {
                        # Wait for completion
                        if ($object.Async.Wait($asyncWaitMilliseconds) -eq $true)
                        {
                            [Void]$pingHash[$object.Host].Add(@{
                                    Host          = $object.Host
                                    RoundtripTime = $object.Async.Result.RoundtripTime
                                    Status        = $object.Async.Result.Status
                                })
                            continue
                        }
                    }
                    catch
                    {
                        # The Wait() method can throw an exception if the host does not exist.
                        if ($object.Async.IsCompleted -eq $true)
                        {
                            [Void]$pingHash[$object.Host].Add(@{
                                    Host          = $object.Host
                                    RoundtripTime = $object.Async.Result.RoundtripTime
                                    Status        = $object.Async.Result.Status
                                })
                            continue
                        }
                        else
                        {
                            Write-Warning -Message ('Unhandled exception: {0}' -f $_.Exception.Message)
                        }
                    }

                    $queue.Enqueue($object)
                }

                # Using the ping results in pingHash, calculate the average RoundtripTime
                foreach ($key in $pingHash.Keys)
                {
                    $pingStatus = $pingHash.$key.Status | Select-Object -Unique

                    if ($pingStatus -eq [System.Net.NetworkInformation.IPStatus]::Success)
                    {
                        $online = $true
                        $status = [System.Net.NetworkInformation.IPStatus]::Success
                    }
                    elseif ($pingStatus.Count -eq 1)
                    {
                        $online = $false
                        $status = $pingStatus
                    }
                    else
                    {
                        $online = $false
                        $status = [System.Net.NetworkInformation.IPStatus]::Unknown
                    }

                    if ($online -eq $true)
                    {
                        $latency = [System.Collections.ArrayList]::new()
                        foreach ($value in $pingHash.$key)
                        {
                            if (-not([String]::IsNullOrWhiteSpace($value.RoundtripTime)))
                            {
                                [Void]$latency.Add($value.RoundtripTime)
                            }
                        }

                        $measuredLatency = $latency | Measure-Object -Average -Sum
                        if ($measuredLatency.Average)
                        {
                            $roundtripAverage = [Math]::Round($measuredLatency.Average, 0)
                        }
                        elseif ($measuredLatency.Sum -eq 0)
                        {
                            $roundtripAverage = 0
                        }
                        else
                        {
                            $roundtripAverage = $null
                        }
                    }
                    else
                    {
                        $roundtripAverage = $null
                    }

                    [FastPingResponse]::new(
                        $key,
                        $roundtripAverage,
                        $online,
                        $status
                    )
                } # End result processing

                # Increment the loop counter
                $loopCounter++

                if ($loopCounter -lt $Count -or $Continuous -eq $true)
                {
                    $timeToSleep = $Interval - $loopTimer.Elapsed.TotalMilliseconds
                    if ($timeToSleep -gt 0)
                    {
                        Start-Sleep -Milliseconds $timeToSleep
                    }
                }
                else
                {
                    break
                }
            }
        }
        catch
        {
            throw
        }
        finally
        {
            $loopTimer.Stop()
        }

    } # End Process
}

<#
    .SYNOPSIS
    Performs a ping sweep against a series of target IP Addresses.

    .DESCRIPTION
    This function calculates the list of IP Addresses to target, and wraps
    a call to Invoke-FastPingto perform the ping sweep.

    .PARAMETER StartIP
    The IP Address to start from.

    .PARAMETER EndIp
    The IP Address to finish with.

    .PARAMETER IPAddress
    An IP Address, to be matched with an appropriate Subnet Mask.

    .PARAMETER SubnetMask
    A Subnet Mask for network range calculations.

    .EXAMPLE
    Invoke-PingSweep -StartIP '1.1.1.1' -EndIP '1.1.1.5'

    HostName RoundtripAverage Online Status
    -------- ---------------- ------ ------
    1.1.1.3 19 True Success
    1.1.1.4 22 True Success
    1.1.1.1 21 True Success
    1.1.1.2 19 True Success
    1.1.1.5 24 True Success

    .EXAMPLE
    Invoke-PingSweep -IPAddress '1.1.1.1' -SubnetMask '255.255.255.252'

    HostName RoundtripAverage Online Status
    -------- ---------------- ------ ------
    1.1.1.2 21 True Success
    1.1.1.1 16 True Success
#>

function Invoke-PingSweep
{
    [CmdletBinding(DefaultParameterSetName = 'FromStartAndEnd')]
    [Alias('PingSweep', 'psweep')]
    param
    (
        [Parameter(
            Mandatory = $true,
            Position = 0,
            ParameterSetName = 'FromStartAndEnd')]
        [ValidateScript( {[System.Net.IPAddress]$_} )]
        [String] $StartIP,

        [Parameter(
            Mandatory = $true,
            Position = 1,
            ParameterSetName = 'FromStartAndEnd')]
        [ValidateScript( {[System.Net.IPAddress]$_} )]
        [String] $EndIP,

        [Parameter(
            Mandatory = $true,
            Position = 0,
            ParameterSetName = 'FromIPAndMask')]
        [ValidateScript( {[System.Net.IPAddress]$_} )]
        [String] $IPAddress,

        [Parameter(
            Mandatory = $true,
            Position = 1,
            ParameterSetName = 'FromIPAndMask')]
        [ValidateScript( {[System.Net.IPAddress]$_} )]
        [String] $SubnetMask
    )

    switch ($PSCmdlet.ParameterSetName)
    {
        'FromIPAndMask'
        {
            $getNetworkRange = @{
                IPAddress  = $IPAddress
                SubnetMask = $SubnetMask
            }
        }
        'FromStartAndEnd'
        {
            $getNetworkRange = @{
                StartIPAddress = $StartIP
                EndIPAddress   = $EndIP
            }
        }
    }

    $networkRange = (GetNetworkRange @getNetworkRange).IPAddressToString
    Invoke-FastPing -HostName $networkRange
}