Noveris.NetScan.psm1

<#
#>


#Requires -Modules @{"ModuleName"="Noveris.SvcProc";"RequiredVersion"="0.1.3"}

########
# Global settings
$ErrorActionPreference = "Stop"
$InformationPreference = "Continue"
Set-StrictMode -Version 2

<#
#>

Function Convert-BigIntegerToIPAddress
{
    [CmdletBinding()]
    [OutputType([System.Net.IPAddress])]
    param(
        [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName)]
        [ValidateNotNull()]
        [System.Numerics.BigInteger]$Address,

        [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName)]
        [ValidateSet(4,16)]
        [int]$Length
    )

    process
    {
        # Create an empty array to hold the BigInteger
        $target = [array]::CreateInstance([byte], $Length)

        # Validate can fit in relevant address family
        $bytes = $Address.ToByteArray()
        if ($bytes.Length -gt $Length)
        {
            Write-Error "BigInteger is too large to fit address family"
        }

        # Copy BigInteger over array and reverse if we are little endian
        [array]::Copy($bytes, $target, $bytes.Length)
        if ([BitConverter]::IsLittleEndian)
        {
            [array]::Reverse($bytes)
        }

        # Create a new IPAddress based on the byte array and output
        [IPAddress]::New($bytes)
    }
}

<#
#>

Function Convert-IPAddressToBigInteger
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true,ValueFromPipeline)]
        [ValidateNotNullOrEmpty()]
        [IPAddress]$IPAddress
    )

    process
    {
        $bytes = $IPAddress.GetAddressBytes()

        # Convert byte ranges, if we're little endian
        if ([BitConverter]::IsLittleEndian)
        {
            [Array]::Reverse($bytes)
        }

        # Create the BigInteger
        $address = [System.Numerics.BigInteger]::New($bytes)

        # Determine the address length
        $addressLength = 0
        switch ($IPAddress.AddressFamily)
        {
            "InterNetworkV6" {
                $addressLength = 16
            }

            "InterNetwork" {
                $addressLength = 4
            }

            default {
                Write-Error "Unsupported address family type"
            }
        }

        [PSCustomObject]@{
            Length = $addressLength
            Address = $address
        }
    }
}

<#
#>

Function New-NetScanCollection
{
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')]
    [CmdletBinding()]
    param(
    )

    process
    {
        [PSCustomObject]@{
            Processing = New-Object 'System.Collections.Generic.List[HashTable]'
            IPv4Systems = @{}
            IPv6Systems = @{}
            Ranges = @{}
        }
    }
}

<#
#>

Function Test-NetScanValidCollection
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true)]
        [ValidateNotNull()]
        [PSCustomObject]$Collection
    )

    process
    {
        if (($Collection | Get-Member).Name -notcontains "Processing" -or
            $Collection.Processing.GetType() -ne ([System.Collections.Generic.List[HashTable]]))
        {
            Write-Error "Invalid or missing Processing parameter in collection"
        }

        if (($Collection | Get-Member).Name -notcontains "IPv4Systems" -or
            $Collection.IPv4Systems.GetType() -ne ([HashTable]))
        {
            Write-Error "Invalid or missing IPv4Systems parameter in collection"
        }

        if (($Collection | Get-Member).Name -notcontains "IPv6Systems" -or
            $Collection.IPv6Systems.GetType() -ne ([HashTable]))
        {
            Write-Error "Invalid or missing IPv6Systems parameter in collection"
        }

        if (($Collection | Get-Member).Name -notcontains "Ranges" -or
            $Collection.Ranges.GetType() -ne ([HashTable]))
        {
            Write-Error "Invalid or missing Ranges parameter in collection"
        }
    }
}

<#
#>

Function Add-NetScanSystemEntry
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true)]
        [ValidateNotNull()]
        [PSCustomObject]$Collection,

        [Parameter(Mandatory=$true)]
        [ValidateNotNull()]
        [IPAddress]$IPAddress,

        [Parameter(Mandatory=$false)]
        [ValidateNotNull()]
        [HashTable]$Properties = @{},

        [Parameter(Mandatory=$false)]
        [ValidateNotNull()]
        [string]$PropertyPrefix = ""
    )

    process
    {
        # Verify the collection
        Test-NetScanValidCollection -Collection $Collection

        # Check that we have a supported address family
        switch ($IPAddress.AddressFamily)
        {
            "InterNetworkV6" {
                $systems = $Collection.IPv6Systems
                break
            }

            "InterNetwork" {
                $systems = $Collection.IPv4Systems
                break
            }

            default { Write-Error "Unsupported address family type" }
        }

        # Generate a BigInteger from the address for indexing
        $addressInt = Convert-IPAddressToBigInteger -IPAddress $IPAddress

        # Add the entry, if it doesn't already exist
        # Include the 'IPAddress' object in the entry
        if (!$systems.ContainsKey($addressInt.Address))
        {
            $systems[$addressInt.Address] = [ordered]@{
                Address = $IPAddress
            }
        }

        $system = $systems[$addressInt.Address]

        # Add any properties that are defined for this system
        foreach ($key in $Properties.Keys)
        {
            $newKey = ("{0}{1}" -f $PropertyPrefix, $key)

            # Filter out any reserved property names and add anything else
            switch ($newKey)
            {
                "Address" { break }
                "Error" { break }
                default { $system[$newKey] = $Properties[$key] }
            }
        }

        # Pass the HashTable on in the pipeline
        $system
    }
}

<#
#>

Function Get-NetScanRanges
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true,ValueFromPipeline)]
        [ValidateNotNull()]
        [PSCustomObject]$Collection
    )

    process
    {
        $Collection.Ranges.Keys | ForEach-Object {
            $Collection.Ranges[$_]
        }
    }
}

<#
#>

Function Add-NetScanRange
{
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$false,ValueFromPipeline)]
        [ValidateNotNull()]
        [PSCustomObject]$Collection = (New-NetScanCollection),

        [Parameter(Mandatory=$false)]
        [ValidateNotNullOrEmpty()]
        [string]$Name,

        [Parameter(Mandatory=$true)]
        [ValidateNotNull()]
        [IPAddress]$RangeStart,

        [Parameter(Mandatory=$true)]
        [ValidateNotNull()]
        [IPAddress]$RangeEnd,

        [Parameter(Mandatory=$false)]
        [ValidateNotNull()]
        [HashTable]$Properties = @{},

        [Parameter(Mandatory=$false)]
        [ValidateNotNull()]
        [string]$PropertyPrefix = ""
    )

    process
    {
        # Verify the collection
        Test-NetScanValidCollection -Collection $Collection

        # Convert and verify the start and end addresses
        $rangeStartInt = Convert-IPAddressToBigInteger -IPAddress $RangeStart
        $rangeEndInt = Convert-IPAddressToBigInteger -IPAddress $RangeEnd

        # Ensure both addresses are the same family type
        if ($RangeStart.AddressFamily -ne $RangeEnd.AddressFamily)
        {
            Write-Error "Begin and end address are different address families"
        }

        # Make sure the begin address is less than or equal to the end address
        if ($rangeStartInt.Address.CompareTo($rangeEndInt.Address) -gt 0)
        {
            Write-Error "Start address is greater than the end address"
        }

        # Iterate through the addresses using BigIntegers
        $current = $rangeStartInt.Address
        while ($current.CompareTo($rangeEndInt.Address) -le 0)
        {
            # Convert index/BigInteger to IPAddress
            $ipAddress = Convert-BigIntegerToIPAddress -Address $current -Length $rangeStartInt.Length

            # Add the IPAddress to the collection
            Add-NetScanAddress -Collection $Collection -IPAddress $ipAddress -Properties $Properties -PropertyPrefix $PropertyPrefix | Out-Null

            $current = [System.Numerics.BigInteger]::Add($current, 1)
        }

        # Add the range to the collection, if a name has been supplied
        if ($PSBoundParameters.Keys -contains "Name")
        {
            $Collection.Ranges[$Name] = [PSCustomObject]@{
                Name = $Name
                RangeStart = $RangeStart
                RangeEnd = $RangeEnd
            }
        }

        # Pass collection on in pipeline
        $Collection
    }
}

<#
#>

Function Select-NetScanSystems
{
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '')]
    [CmdletBinding(DefaultParameterSetName="All")]
    param(
        [Parameter(Mandatory=$true,ValueFromPipeline,ParameterSetName="All")]
        [Parameter(Mandatory=$true,ValueFromPipeline,ParameterSetName="Range")]
        [ValidateNotNull()]
        [PSCustomObject]$Collection,

        [Parameter(Mandatory=$true,ParameterSetName="All")]
        [switch]$All,

        [Parameter(Mandatory=$true,ParameterSetName="Range")]
        [ValidateNotNull()]
        [IPAddress]$RangeStart,

        [Parameter(Mandatory=$true,ParameterSetName="Range")]
        [ValidateNotNull()]
        [IPAddress]$RangeEnd
    )

    process
    {
        # Verify the collection
        Test-NetScanValidCollection -Collection $Collection

        switch ($PSCmdlet.ParameterSetName)
        {
            "Range" {
                # Convert and verify the start and end addresses
                $rangeStartInt = Convert-IPAddressToBigInteger -IPAddress $RangeStart
                $rangeEndInt = Convert-IPAddressToBigInteger -IPAddress $RangeEnd

                # Ensure both addresses are the same family type
                if ($RangeStart.AddressFamily -ne $RangeEnd.AddressFamily)
                {
                    Write-Error "Begin and end address are different address families"
                }

                # Make sure the begin address is less than or equal to the end address
                if ($rangeStartInt.Address.CompareTo($rangeEndInt.Address) -gt 0)
                {
                    Write-Error "Start address is greater than the end address"
                }

                # Check that we have a supported address family
                $systems = $null
                switch ($RangeStart.AddressFamily)
                {
                    "InterNetworkV6" {
                        $systems = $Collection.IPv6Systems
                        break
                    }

                    "InterNetwork" {
                        $systems = $Collection.IPv4Systems
                        break
                    }

                    default { Write-Error "Unsupported address family type" }
                }

                # Iterate through the addresses using BigIntegers
                $matchSystems = $systems.Keys | Sort-Object |
                    Where-Object { $_.CompareTo($rangeStartInt.Address) -ge 0 -and $_.CompareTo($rangeEndInt.Address) -le 0} |
                    ForEach-Object {
                        [PSCustomObject]($systems[$_])
                    }

                $matchSystems
                break
            }

            "All" {
                @($Collection.IPv4Systems, $Collection.IPv6Systems) | ForEach-Object {
                    $systems = $_
                    $systems.Keys | Sort-Object | ForEach-Object {
                        [PSCustomObject]($systems[$_])
                    }
                }
                break
            }

            default { Write-Error "Unknown ParameterSetName" }
        }

    }
}

<#
#>

Function Add-NetScanAddress
{
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$false,ValueFromPipeline)]
        [ValidateNotNull()]
        [PSCustomObject]$Collection = (New-NetScanCollection),

        [Parameter(Mandatory=$true)]
        [ValidateNotNull()]
        [IPAddress]$IPAddress,

        [Parameter(Mandatory=$false)]
        [ValidateNotNull()]
        [HashTable]$Properties = @{},

        [Parameter(Mandatory=$false)]
        [ValidateNotNull()]
        [string]$PropertyPrefix = ""
    )

    process
    {
        Add-NetScanSystemEntry -Collection $Collection -IPAddress $IPAddress -Properties $Properties -PropertyPrefix $PropertyPrefix | Out-Null

        # Pass collection on in pipeline
        $Collection
    }
}

<#
#>

Function Add-NetScanPingCheck
{
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$false,ValueFromPipeline)]
        [ValidateNotNull()]
        [PSCustomObject]$Collection = (New-NetScanCollection)
    )

    process
    {
        # Verify the collection
        Test-NetScanValidCollection -Collection $Collection

        # Add the ping check script to the processing list
        $Collection.Processing.Add(@{
            Name = "Ping"
            Script = {
                param(
                    [Parameter(Mandatory=$true)]
                    [ValidateNotNull()]
                    [HashTable]$System
                )

                $ErrorActionPreference = "Stop"
                Set-StrictMode -Version 2

                $address = $System["Address"]

                $status = @{
                    Address = $address
                    Ping = ""
                    Error = ""
                }

                try {
                    # Ping the remote host
                    $pingRequest = New-Object System.Net.NetworkInformation.Ping
                    $total = 4

                    # Send a series of echo requests to the target. Stop on first success.
                    for ($count = 0; $count -lt $total ; $count++) {
                        $reply = $pingRequest.Send($Address)
                        if ($reply.Status -eq "Success")
                        {
                            $status["Ping"] = "True"
                            break
                        }

                        Start-Sleep 1
                    }
                } catch {
                    $status["Error"] = $_.ToString()
                }

                $status
            }
        })

        # Pass the collection on in the pipeline
        $Collection
    }
}

<#
#>

Function Add-NetScanTcpCheck
{
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$false,ValueFromPipeline)]
        [ValidateNotNull()]
        [PSCustomObject]$Collection = (New-NetScanCollection),

        [Parameter(Mandatory=$false)]
        [ValidateNotNull()]
        [int[]]$Ports = @(80, 443, 445, 22, 3389)
    )

    process
    {
        # Verify the collection
        Test-NetScanValidCollection -Collection $Collection

        $Collection.Processing.Add(@{
            Name = "Tcp"
            Args = @{
                Ports = $Ports
            }
            Script = {
                param(
                    [Parameter(Mandatory=$true)]
                    [ValidateNotNull()]
                    [HashTable]$System,

                    [Parameter(Mandatory=$true)]
                    [ValidateNotNull()]
                    [int[]]$Ports
                )

                $ErrorActionPreference = "Stop"
                Set-StrictMode -Version 2

                $address = $System["Address"]

                $status = @{
                    Address = $address
                    TcpPorts = New-Object System.Collections.Generic.List[int]
                    Error = ""
                }

                foreach ($port in $Ports) {
                    # Check this tcp port on the remote host
                    $client = [System.Net.Sockets.TCPClient]::New()
                    try {
                        # We don't care about the result of the task, just whether the client
                        # became connected within the timeout period
                        $client.ConnectAsync($address, $port) | Out-Null

                        for ($count = 0; $count -lt 5 ; $count++)
                        {
                            if ($client.Connected)
                            {
                                $status["TcpPorts"].Add($port)
                                break
                            }

                            Start-Sleep -Seconds 1
                        }
                    } catch {
                        # Ignore error here. Either way, it is unavailable.
                        $status["Error"] = $_
                    }

                    try {
                        $client.Close()
                        $client.Dispose()
                    } catch {
                        $status["Error"] = $_.ToString()
                        break
                    }
                }

                $status
            }
        })

        # Pass collection on in pipeline
        $Collection
    }
}

<#
#>

Function Add-NetScanReverseLookup
{
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingEmptyCatchBlock', '')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$false,ValueFromPipeline)]
        [ValidateNotNull()]
        [PSCustomObject]$Collection = (New-NetScanCollection)
    )

    process
    {
        # Verify the collection
        Test-NetScanValidCollection -Collection $Collection

        # Add processing script to processing list
        $Collection.Processing.Add(@{
            Name = "ReverseLookup"
            Script = {
                param(
                    [Parameter(Mandatory=$true)]
                    [ValidateNotNull()]
                    [HashTable]$System
                )

                $ErrorActionPreference = "Stop"
                Set-StrictMode -Version 2

                $address = $System["Address"]

                $status = @{
                    Address = $address
                    ReverseHostname = ""
                    Error = ""
                }

                try {
                    $resolve = [System.Net.Dns]::GetHostByAddress($address)
                    if (![string]::IsNullOrEmpty($resolve.HostName))
                    {
                        $status["ReverseHostname"] = $resolve.HostName
                    }
                } catch {
                }

                $status
            }
        })

        # Pass collection on in pipeline
        $Collection
    }
}

<#
#>

Function Update-NetScanConnectivityInfo
{
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingEmptyCatchBlock', '')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true,ValueFromPipeline)]
        [ValidateNotNull()]
        [PSCustomObject]$Collection,

        [Parameter(Mandatory=$false)]
        [ValidateNotNull()]
        [int]$ConcurrentChecks = 80,

        [Parameter(Mandatory=$false)]
        [ValidateNotNull()]
        [int]$LowWatermark = 300,

        [Parameter(Mandatory=$false)]
        [switch]$LogProgress = $false
    )

    process
    {
        # Create runspace environment
        Write-Verbose "Creating runspace pool"
        $pool = [RunSpaceFactory]::CreateRunspacePool(1, $ConcurrentChecks)
        if (($pool | Get-Member).Name -contains "ApartmentState")
        {
            $pool.ApartmentState = "MTA"
        }
        $pool.Open()
        $runspaces = New-Object System.Collections.Generic.List[PSCustomObject]

        # Script block for processing completed runspaces
        $processScript = {
            param($result)

            $ErrorActionPreference = "Stop"
            Set-StrictMode -Version 2

            #Write-Host "Received Result: $result"
            #Write-Host ("TargetState eq null: " + ($targetState -eq $null))
            #Write-Host $connectionState.Keys
            #Write-Host ("Connection State contains target: " + ($connectionState.Keys -contains $state.Target))

            #$state = $result | ConvertFrom-Json
            $state = $result
            if ($state.ContainsKey("Error") -and ![string]::IsNullOrEmpty($state["Error"]))
            {
                Write-Warning ("Error during check on address {0}: {1}" -f $state["Address"], $state["Error"])
                return
            }

            # Add/Update the system entry
            Add-NetScanSystemEntry -Collection $Collection -IPAddress $state["Address"] -Properties $state | Out-Null
        }

        foreach ($entry in $Collection.Processing)
        {
            foreach ($systemCollection in @($Collection.IPv4Systems, $Collection.IPv6Systems))
            {
                foreach ($key in $systemCollection.Keys)
                {
                    # Wait for runspace count to reach low water mark before proceeding
                    $runspaces = (Wait-NetScanCompletedRunspaces -Runspaces $runspaces -LowWatermark $LowWatermark -ProcessScript $processScript).Runspaces

                    $system = $systemCollection[$key]

                    $script = $entry["Script"]
                    $name = $entry["Name"]

                    Write-Verbose ("Scheduling processing script ({0}) for ({1})" -f $name, $system["Address"])
                    $runspace = [PowerShell]::Create()
                    $runspace.AddScript($script) | Out-Null
                    $runspace.AddParameter("System", ([HashTable]$system).Clone()) | Out-Null
                    if ($entry.ContainsKey("Args"))
                    {
                        foreach ($argName in $entry["Args"].Keys)
                        {
                            $runspace.AddParameter($argName, $entry["Args"][$argName])
                        }
                    }
                    $runspace.RunspacePool = $pool

                    $runspaces.Add([PSCustomObject]@{
                        Runspace = $runspace
                        Status = $runspace.BeginInvoke()
                    })
                }
            }
        }

        # Wait for the runspace count to reach zero
        if ($LogProgress)
        {
            Write-Information "Waiting for remainder of runspaces to finish"
        }

        Write-Verbose "Waiting for remainder of runspaces to finish"
        $runspaces = (Wait-NetScanCompletedRunspaces -Runspaces $runspaces -LowWatermark 0 -ProcessScript $processScript).Runspaces

        # Close off the runspace pool
        $pool.Close()
        $pool.Dispose()

        # Fill all properties across all objects
        Write-Verbose "Determining properties across all objects"
        $properties = $($Collection.IPv4Systems, $Collection.IPv6Systems) | ForEach-Object {
            $_.Values | ForEach-Object { $_.Keys }
        } | Select-Object -Unique

        Write-Information "Properties: $properties"

        $memberAdditions = 0
        $($Collection.IPv4Systems, $Collection.IPv6Systems) | ForEach-Object {
            $_.Values | ForEach-Object {
                foreach ($prop in $properties)
                {
                    if ($_.Keys -notcontains $prop)
                    {
                        $_[$prop] = $null
                        $memberAdditions++
                    }
                }
            }
        }

        Write-Verbose "Filled $memberAdditions properties across objects"

        # Pass collection on in pipeline
        $Collection
    }
}

<#
#>

Function Wait-NetScanCompletedRunspaces
{
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true)]
        [AllowEmptyCollection()]
        [ValidateNotNull()]
        [System.Collections.Generic.List[PSCustomObject]]$Runspaces,

        [Parameter(Mandatory=$true)]
        [ValidateNotNull()]
        [int]$LowWaterMark,

        [Parameter(Mandatory=$true)]
        [ValidateNotNull()]
        [ScriptBlock]$ProcessScript
    )

    process
    {
        $working = $Runspaces

        # Process completed tasks until the low water mark is reached
        while (($working | Measure-Object).Count -gt $LowWaterMark)
        {
            # Separate runspaces in to completed and non-completed
            # Don't use Where-Object here to avoid checking IsCompleted twice, which
            # may change between checks.
            # This process only performs the IsCompleted check once, which is more reliable
            # when processes are running concurrently.
            $tempList = New-Object System.Collections.Generic.List[PSCustomObject]
            $completeList = New-Object System.Collections.Generic.List[PSCustomObject]
            $working | ForEach-Object {
                if ($_.Status.IsCompleted)
                {
                    $completeList.Add($_)
                } else {
                    $tempList.Add($_)
                }
            }
            $working = $tempList

            # Display diagnostic information on completed runspaces
            $completeCount = ($completeList | Measure-Object).Count
            if ($completeCount -gt 0)
            {
                Write-Verbose "Found $completeCount runspaces to finalise"
            }

            # Process completed runspaces
            $completeList | ForEach-Object {
                $runspace = $_

                $result = $null
                try {
                    $result = $runspace.Runspace.EndInvoke($runspace.Status)

                    try {
                        # Call supplied script block with result of run
                        Invoke-Command -ScriptBlock $ProcessScript -ArgumentList $result | Out-Null
                    } catch {
                        Write-Warning "Error during call of processing script: $_"
                    }
                } catch {
                    Write-Warning "Error reading return from runspace job: $_"
                }

                $runspace.Runspace.Dispose()
                $runspace.Status = $null
            }

            Start-Sleep -Seconds 1
        }

        [PSCustomObject]@{
            Runspaces = $working
        }
    }
}