Private/Probe.ps1

# How Gatherers reach the network.
#
# Both of these are Gatherer machinery: they talk to something outside this machine and
# decide nothing about what comes back. They are shared because the Network Kind and the
# Server connectivity Kind measure the same two things - can I reach it, and how long does
# it take - and a Technician comparing a gateway against a database server is comparing
# numbers that must have been produced the same way.
#
# Deliberately untested, like every Gatherer: faking a ping tests the fake.

function Measure-Latency {
    <#
    .SYNOPSIS
        Pings a target repeatedly and reports what came back. Judges none of it.
    .DESCRIPTION
        Loss matters more than latency for the problems Gutcheck exists to find - a client
        that loses one packet in fifty hangs rather than slows - so a window of pings is
        sent rather than one, and the loss count is reported alongside the timings.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [Parameter(Mandatory)][string]$Target,
        [int]$Count = 20,
        [int]$TimeoutMs = 1000
    )

    $ping = New-Object System.Net.NetworkInformation.Ping
    try {
        $replies = for ($i = 0; $i -lt $Count; $i++) {
            # Collected as the replies, so the progress call must emit nothing - it doesn't.
            Write-CheckProgress -Status ((Get-Text 'Console.Progress.Ping') -f $Target) -Step $i -Of $Count
            try { $ping.Send($Target, $TimeoutMs) } catch { $null }
            Start-Sleep -Milliseconds 100
        }
    }
    finally { $ping.Dispose() }

    $ok = @($replies | Where-Object { $_ -and $_.Status -eq 'Success' })

    # Where some pings went missing and not all, how busy the machine is: a machine at
    # full load loses pings on a line that is in order, and the Judge is to know.
    $load = $null
    if ($ok.Count -and $ok.Count -lt $Count) { $load = Get-MachineLoadPercent }

    [pscustomobject]@{
        PSTypeName  = 'Gutcheck.Data.Latency'
        CpuPercent  = $load
        Target      = $Target
        Sent        = $Count
        Received    = $ok.Count
        Lost        = $Count - $ok.Count
        LossPercent = [math]::Round((1 - $ok.Count / [double]$Count) * 100)
        AverageMs   = $(if ($ok.Count) { [math]::Round(($ok | Measure-Object RoundtripTime -Average).Average, 1) } else { $null })
        MaximumMs   = $(if ($ok.Count) { ($ok | Measure-Object RoundtripTime -Maximum).Maximum } else { $null })
    }
}

function Get-MachineLoadPercent {
    <#
    .SYNOPSIS
        How busy the processors are right now, in percent, or $null where Windows does
        not say. Never throws.
    #>

    [CmdletBinding()]
    [OutputType([double])]
    param()

    try {
        $total = Get-CimInstance Win32_PerfFormattedData_PerfOS_Processor -Filter "Name='_Total'" -ErrorAction Stop
        if ($null -eq $total) { return $null }
        [double]$total.PercentProcessorTime
    }
    catch { $null }
}

function Measure-TcpConnect {
    <#
    .SYNOPSIS
        Opens a TCP connection and reports how long it took, or $null when it did not open.
    .DESCRIPTION
        A single connect is not evidence: a firewall that drops one connection in five is
        the thing that makes a business application feel unreliable, and one successful
        probe would report it as healthy. Callers connect repeatedly and judge the spread.
    #>

    [CmdletBinding()]
    [OutputType([double])]
    param(
        [Parameter(Mandatory)][string]$HostName,
        [Parameter(Mandatory)][int]$Port,
        [int]$TimeoutMs = 3000
    )

    $client = New-Object System.Net.Sockets.TcpClient
    try {
        $watch = [Diagnostics.Stopwatch]::StartNew()
        $task  = $client.ConnectAsync($HostName, $Port)
        if ($task.Wait($TimeoutMs) -and $client.Connected) {
            return [math]::Round($watch.Elapsed.TotalMilliseconds, 1)
        }
        $null
    }
    catch { $null }
    finally { $client.Dispose() }
}

function Resolve-HostAddress {
    <#
    .SYNOPSIS
        Resolves a host name and reports how long it took. Reports failure; judges nothing.
    .DESCRIPTION
        Slow name resolution delays every connection an application makes, so the duration
        is as much of the answer as the address is.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [Parameter(Mandatory)][string]$HostName,
        # Ask the DNS client first whether it holds an answer for the name: then the time
        # below is the cache's, not a DNS server's, and Cached says so.
        [switch]$CheckCache
    )

    $cached = $null
    if ($CheckCache) { $cached = Test-DnsClientCacheEntry -HostName $HostName }

    $watch = [Diagnostics.Stopwatch]::StartNew()
    try {
        $address = @([Net.Dns]::GetHostAddresses($HostName))[0]
        $watch.Stop()
        [pscustomobject]@{
            PSTypeName   = 'Gutcheck.Data.Resolution'
            HostName     = $HostName
            Address      = "$address"
            Resolved     = $true
            Milliseconds = $watch.Elapsed.TotalMilliseconds
            Cached       = $cached
        }
    }
    catch {
        $watch.Stop()
        [pscustomobject]@{
            PSTypeName   = 'Gutcheck.Data.Resolution'
            HostName     = $HostName
            Address      = $null
            Resolved     = $false
            Milliseconds = $null
            Cached       = $cached
        }
    }
}

function Test-DnsClientCacheEntry {
    <#
    .SYNOPSIS
        Whether the DNS client of Windows holds an answer for a name: $true, $false, or
        $null where Windows has no cmdlet to ask. Asks; changes nothing. Never throws.
    .DESCRIPTION
        A name the cache holds is answered from memory in well under a millisecond, and
        that time says nothing about a DNS server. A short name is cached under its full
        name (FS01 as FS01.contoso.example), so an entry that begins with the name and a
        dot counts too. Only answers count: a name that did not exist is cached as well,
        with a status other than 0.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    # -Name too: the HttpsEndpoint and Website Gatherers ask by that name.
    param([Alias('Name')][AllowNull()][AllowEmptyString()][string]$HostName)

    if (-not $HostName -or -not (Get-Command -Name Get-DnsClientCache -ErrorAction SilentlyContinue)) { return $null }
    $name = $HostName.Trim().TrimEnd('.')
    if (-not $name) { return $false }
    $entries = @()
    try { $entries = @(Get-DnsClientCache -Entry ('{0}*' -f $name) -ErrorAction SilentlyContinue) } catch { return $null }
    $pattern = '{0}.*' -f [System.Management.Automation.WildcardPattern]::Escape($name)
    [bool]@($entries | Where-Object { "$($_.Status)" -eq '0' -and ("$($_.Entry)" -eq $name -or "$($_.Entry)" -like $pattern) }).Count
}

function Get-AddressRoute {
    <#
    .SYNOPSIS
        Which adapter Windows would send to an address through, as plain data; nothing
        where it cannot say. Never throws.
    .DESCRIPTION
        Asked of Find-NetRoute, which chooses as Windows chooses for a connection. The
        adapter's description and interface type are read beside it, because whether that
        is a VPN is what the Zone of a Measurement turns on - and that is the Judge's to
        decide (Get-MeasurementRouteKind), not this.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][AllowEmptyString()][string]$Address)

    if (-not $Address -or -not (Get-Command -Name Find-NetRoute -ErrorAction SilentlyContinue)) { return }
    try {
        $route = @(Find-NetRoute -RemoteIPAddress $Address -ErrorAction Stop | Where-Object { $_.PSObject.Properties['NextHop'] }) | Select-Object -First 1
        if (-not $route) { return }
        $adapter = Get-NetAdapter -InterfaceIndex $route.InterfaceIndex -IncludeHidden -ErrorAction SilentlyContinue | Select-Object -First 1
        [pscustomobject]@{
            PSTypeName           = 'Gutcheck.Data.Route'
            Address              = $Address
            InterfaceIndex       = $route.InterfaceIndex
            InterfaceAlias       = "$($route.InterfaceAlias)"
            InterfaceDescription = "$($adapter.InterfaceDescription)"
            InterfaceType        = $(if ($null -ne $adapter.InterfaceType) { [int]$adapter.InterfaceType } else { $null })
            NextHop              = "$($route.NextHop)"
        }
    }
    catch { }
}

function Get-OwnPrefix {
    <#
    .SYNOPSIS
        The machine's own addresses, each with its prefix length: what its own subnets
        are. Nothing where Windows would not say. Never throws.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param()

    if (-not (Get-Command -Name Get-NetIPAddress -ErrorAction SilentlyContinue)) { return }
    try {
        Get-NetIPAddress -ErrorAction Stop | ForEach-Object {
            [pscustomobject]@{
                InterfaceIndex = $_.InterfaceIndex
                InterfaceAlias = "$($_.InterfaceAlias)"
                Address        = "$($_.IPAddress)"
                PrefixLength   = $_.PrefixLength
            }
        }
    }
    catch { }
}

function Test-WindowsServer {
    <#
    .SYNOPSIS
        Whether this Windows is a server: $true, $false, or $null where Windows did not
        say. Asks Windows; changes nothing.
    .DESCRIPTION
        ProductType of Win32_OperatingSystem, as Microsoft documents it: 1 is a
        workstation, 2 a domain controller, 3 a server. Some things a Check reads exist
        on a workstation only - the Security Center, the recording of how long a start
        took - and their absence on a server is to be said as that.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param()

    try {
        $type = [int](Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop).ProductType
        if ($type -eq 1) { return $false }
        if ($type -eq 2 -or $type -eq 3) { return $true }
    }
    catch { }
    $null
}