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

    # From the counter and not through WMI (see Private/Counter.ps1), kept open for the
    # Run. The counter is a rate since it was last read: over a few milliseconds it is
    # 0 or 100, so a call that comes sooner than that waits for the rest.
    if (-not $script:MachineLoadCounter) {
        $script:MachineLoadCounter = Open-PerformanceCounter -Path $script:MachineLoadPath
        if (-not $script:MachineLoadCounter) { return $null }
        $script:MachineLoadReadAt = [DateTime]::UtcNow
    }
    $since = ([DateTime]::UtcNow - $script:MachineLoadReadAt).TotalMilliseconds
    if ($since -lt $script:MachineLoadMinimumMs) { Start-Sleep -Milliseconds ([int]($script:MachineLoadMinimumMs - $since)) }
    $load = (Read-PerformanceCounter -Counter $script:MachineLoadCounter)[$script:MachineLoadPath]
    $script:MachineLoadReadAt = [DateTime]::UtcNow
    if ($null -eq $load) { return $null }
    [double]$load
}

$script:MachineLoadPath      = '\Processor(_Total)\% Processor Time'
$script:MachineLoadCounter   = $null
$script:MachineLoadReadAt    = [DateTime]::MinValue
$script:MachineLoadMinimumMs = 250

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. The address is the one every Check
        shows a Server at (Select-ServerAddress): IPv4 first, in the order DNS answered.
    #>

    [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 = Select-ServerAddress -Address @([Net.Dns]::GetHostAddresses($HostName))
        $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 {
        # Asked quietly: an address with no way to it is an answer, and a thrown error stood in
        # console.log as a TerminatingError.
        $route = @(Find-NetRoute -RemoteIPAddress $Address -ErrorAction SilentlyContinue | 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
}