Noveris.NetScan.psm1

<#
#>


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

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

<#
#>

Function New-NetScanRange
{
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true)]
        [ValidateNotNullOrEmpty()]
        [string]$Name,

        [Parameter(Mandatory=$true)]
        [ValidateNotNullOrEmpty()]
        [string]$RangeStart,

        [Parameter(Mandatory=$true)]
        [ValidateNotNullOrEmpty()]
        [string]$RangeEnd,

        [Parameter(Mandatory=$false)]
        [bool]$Ping = $true,

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

    process
    {
        $range = [PSCustomObject]@{
            Name = $Name
            RangeStart = $RangeStart
            RangeEnd = $RangeEnd
            Ping = $Ping
            TcpPorts = $TcpPorts
        }

        Get-NetScanRangeData -Range $range | Out-Null

        $range
    }
}

<#
#>

Function Get-NetScanRangeData
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true)]
        [ValidateNotNull()]
        [PSCustomObject]$Range
    )

    process
    {
        # Check for required properties on the Range object
        @("Name", "RangeStart", "RangeEnd", "Ping", "TcpPorts") | ForEach-Object {
            if (($Range | Get-Member).Name -notcontains $_)
            {
                Write-Error "Missing $_ property on Range object"
            }
        }

        # Validate the start address
        $RangeStart = $Range.RangeStart.ToString()
        $beginParsed = [IPAddress]::Parse($RangeStart)
        $beginBytes = $beginParsed.GetAddressBytes()

        # Validate the end address
        $RangeEnd = $Range.RangeEnd.ToString()
        $endParsed = [IPAddress]::Parse($RangeEnd)
        $endBytes = $endParsed.GetAddressBytes()

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

        # Determine address length and make sure it is a supported address type
        $addressLength = 0
        switch ($beginParsed.AddressFamily)
        {
            "InterNetworkV6" {
                $addressLength = 16
            }

            "InterNetwork" {
                $addressLength = 4
            }

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

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

        # Convert to BigInteger, so we can work with them as integers
        $beginAddress = [System.Numerics.BigInteger]::New($beginBytes)
        $endAddress = [System.Numerics.BigInteger]::New($endBytes)
        Write-Verbose "Begin Address (int): $beginAddress"
        Write-Verbose "End Address (int): $endAddress"

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

        [PSCustomObject]@{
            Name = [string]($Range.Name)
            RangeStartInt = $beginAddress
            RangeEndInt = $endAddress
            AddressLength = $addressLength
            Ping = [bool]$Range.Ping
            TcpPorts = [int[]]($Range.TcpPorts)
        }
    }
}

<#
#>

Function Test-NetScanRangeConnectivity
{
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingEmptyCatchBlock', '')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true)]
        [ValidateNotNullOrEmpty()]
        [PSCustomObject[]]$Ranges,

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

        [Parameter(Mandatory=$false)]
        [switch]$ExcludeUnavailable = $false,

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

    process
    {
        # Convert ranges to range data and check validity
        Write-Verbose "Converting and checking range data"
        $RangeData = $Ranges | ForEach-Object {
            Get-NetScanRangeData -Range $_
        }

        # Create storage for connectivity state
        $connectionState = [Ordered]@{}

        # Create runspace environment
        Write-Verbose "Creating runspace pool"
        $pool = [RunSpaceFactory]::CreateRunspacePool(1, $ConcurrentChecks)
        $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"
            $state = $result | ConvertFrom-Json
            $targetState = $connectionState[$state.Target]
            #Write-Host ("TargetState eq null: " + ($targetState -eq $null))
            #Write-Host $connectionState.Keys
            #Write-Host ("Connection State contains target: " + ($connectionState.Keys -contains $state.Target))

            # If this port or icmp check returned available, overall, the system is available
            if ($state.Available)
            {
                $targetState.Available = $true
            }

            # Update the ping attribute, if this was a ping check
            if ($state.Check -eq "Ping")
            {
                $targetState.Ping = [string]$state.Available
            }

            if ($state.Check -eq "TCP" -and $state.Available)
            {
                $targetState.TcpPorts.Add($state.Port)
            }
        }

        # Script for scanning target
        $checkScript = {
            param(
                [Parameter(Mandatory=$true)]
                [ValidateNotNull()]
                [IPAddress]$Target,

                [Parameter(Mandatory=$false)]
                [bool]$Ping = $false,

                [Parameter(Mandatory=$false)]
                [int]$TcpPort
            )

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

            $status = @{
                Target = $Target.ToString()
                Available = $false
                Port = 0
                Check = "unknown"
                Error = ""
            }

            try {
                if ($Ping)
                {
                    # Ping the remote host
                    $status["Check"] = "Ping"
                    $status["Available"] = $false
                    $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($Target)
                        if ($reply.Status -eq "Success")
                        {
                            $status["Available"] = $true
                            break
                        }

                        Start-Sleep 1
                    }
                }

                if ($PSBoundParameters.Keys -contains "TcpPort")
                {
                    $status["Check"] = "TCP"
                    $status["Port"] = $TcpPort
                    $status["Available"] = $false

                    # 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($Target, $TcpPort) | Out-Null

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

                            Start-Sleep -Seconds 1
                        }
                    } catch {
                        # Ignore error here. Either way, it is unavailable.
                    }

                    try {
                        $client.Close()
                        $client.Dispose()
                    } catch {
                    }
                }

            } catch {
                $status["Error"] = $_.ToString()
            }

            $result = [PSCustomObject]$status | ConvertTo-Json
            #Write-Host "Result: $result"
            $result
        }

        # Loop through all of the ranges supplied
        foreach ($range in $RangeData) {
            $rangeName = $range.Name
            $rangeStartInt = $range.RangeStartInt
            $rangeEndInt = $range.RangeEndInt
            $addressLength = $range.AddressLength
            $ping = $range.Ping
            $tcpPorts = $range.TcpPorts

            if ($LogProgress)
            {
                Write-Information "Scheduling Range: $rangeName"
            }

            # Loop through each address in the range
            $currentAddress = $rangeStartInt
            while ($currentAddress.CompareTo($rangeEndInt) -le 0)
            {
                # Construct a usable address
                $target = [array]::CreateInstance([byte], $addressLength)
                $bytes = $currentAddress.ToByteArray()
                [array]::Copy($bytes, $target, $bytes.Length)

                if ([BitConverter]::IsLittleEndian)
                {
                    [array]::Reverse($bytes)
                }

                $addr = [IPAddress]::New($bytes)
                $addrStr = $addr.ToString()
                Write-Verbose "Scheduling Address: $addrStr"

                if ($LogProgress)
                {
                    Write-Information "Scheduling Address: $addrStr"
                }

                # Add this address to the connection state now to preserve ordering
                $connectionState[$addrStr] = [PSCustomObject]@{
                    Range = $rangeName
                    Name = $addrStr
                    Available = $false
                    Ping = "unknown"
                    TcpPorts = New-Object 'System.Collections.Generic.List[int]'
                }

                # Wait for runspace count to reach low water mark before proceeding
                $runspaces = (Wait-NetScanCompletedRunspaces -Runspaces $runspaces -LowWatermark 300 -ProcessScript $processScript).Runspaces

                # Schedule a ping check, if requested
                if ($Ping)
                {
                    Write-Verbose "Scheduling ping check"
                    $runspace = [PowerShell]::Create()
                    $runspace.AddScript($checkScript) | Out-Null
                    $runspace.AddParameter("Target", $addr) | Out-Null
                    $runspace.AddParameter("Ping", $true) | Out-Null
                    $runspace.RunspacePool = $pool

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

                # Loop through all tcp ports to check and schedule check
                foreach ($port in $TcpPorts) {
                    Write-Verbose "Scheduling tcp check: $port"
                    $runspace = [PowerShell]::Create()
                    $runspace.AddScript($checkScript) | Out-Null
                    $runspace.AddParameter("Target", $addr) | Out-Null
                    $runspace.AddParameter("TcpPort", $port) | Out-Null
                    $runspace.RunspacePool = $pool

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

                # Increment current address using BigInteger static method
                $currentAddress = [System.Numerics.BigInteger]::Add($currentAddress, 1)
            }
        }

        # 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()

        # Generate an array of the systems and state
        $results = $connectionState.Keys | ForEach-Object {
            $connectionState[$_]
        }

        # Exclude systems that are unavailable, if specified
        if ($ExcludeUnavailable)
        {
            $results = $results | Where-Object {$_.Available -eq $true}
        }

        # Output results
        $results
    }
}

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
        }
    }
}

<#
#>

Function Test-NetScanValidConfig
{
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true)]
        [ValidateNotNull()]
        $Config
    )

    process
    {
        "Name", "RangeStart", "RangeEnd" | ForEach-Object {
            $prop = $_
            if (($config | Get-Member).Name -notcontains $prop -or [string]::IsNullOrEmpty($config.$prop))
            {
                Write-Error "Missing $prop in configuration"
            }
        }
    }
}

<#
#>

Function Test-NetScanRangeFromConfig
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true)]
        [ValidateNotNullOrEmpty()]
        [string]$ConfigPath
    )

    # Read configuration from file
    $entries = $null
    try {
        $entries = Get-Content -Encoding UTF8 $ConfigPath | ConvertFrom-Json -Depth 3
        $entries | ForEach-Object { Test-NetScanValidConfig -Config $_ }
    } catch {
        Write-Information "Failed to import configuration: $_"
        throw $_
    }

    # Iterate through each configuration entry
    $ranges = $entries | ForEach-Object {
        $scanParams = @{
            Name = $_.Name
            RangeStart = $_.RangeStart
            RangeEnd = $_.RangeEnd
        }

        if (($_ | Get-Member).Name -contains "Ping")
        {
            $scanParams["Ping"] = $_.Ping
        }

        if (($_ | Get-Member).Name -contains "TcpPorts")
        {
            $scanParams["TcpPorts"] = $_.TcpPorts
        }

        New-NetScanRange @scanParams
    }

    # Perform scan using the scan ranges
    Test-NetScanRangeConnectivity -Ranges $ranges -LogProgress
}

<#
#>

Function Invoke-NetScanService
{
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true)]
        [ValidateNotNullOrEmpty()]
        [string]$ConfigPath,

        [Parameter(Mandatory=$false)]
        [ValidateNotNull()]
        [int]$Iterations = 1,

        [Parameter(Mandatory=$false)]
        [ValidateSet("Start", "Finish")]
        [string]$WaitFrom = "Start",

        [Parameter(Mandatory=$false)]
        [ValidateNotNull()]
        [int]$WaitSeconds = 0,

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

        [Parameter(Mandatory=$false)]
        [int]$RotateSizeKB = 128,

        [Parameter(Mandatory=$false)]
        [int]$PreserveCount = 5,

        [Parameter(Mandatory=$true)]
        [ValidateNotNullOrEmpty()]
        [string]$OutputPath
    )

    process
    {
        # Service command block
        $block = {
            $results = Test-NetScanRangeFromConfig -ConfigPath $ConfigPath

            $ranges = $results | ForEach-Object { $_.Range } | Select-Object -Unique
            foreach ($range in $ranges)
            {
                $outputFile = ([System.IO.Path]::Combine($OutputPath, $range) + ".csv")
                $results | Where-Object { $_.Range -eq $range } | Export-CSV -NoTypeInformation -Path $outputFile
            }
        }

        # Build service parameters
        $serviceParams = @{
            ScriptBlock = $block
            Iterations = $Iterations
            WaitFrom = $WaitFrom
            WaitSeconds = $WaitSeconds
            LogPath = $LogPath
            RotateSizeKB = $RotateSizeKB
            PreserveCount = $PreserveCount
        }

        # Actual service invocation
        Invoke-ServiceRun @serviceParams
    }
}