Private/Kinds/Network.ps1

# The Network Kind: how this machine reaches everything that is not on it.
#
# Nearly every application complaint that turns out not to be the application turns out to
# be here, and the three things that cause it - a weak radio, a lossy link and slow name
# resolution - are all invisible to a Technician looking at the machine.

# How many pings each target gets. The gateway gets more because local loss is the reading
# most often dismissed as noise, and a wider window is what makes it undeniable.
$script:NetworkGatewayPingCount  = 30
$script:NetworkInternetPingCount = 20
$script:NetworkServerPingCount   = 20

# Where the Network Kind looks for the internet and for name resolution. Not Check
# Definition parameters: a Customer may disagree about a threshold, not about whether
# 1.1.1.1 is a reasonable thing to ping.
$script:NetworkInternetTarget = '1.1.1.1'
$script:NetworkDnsTarget      = 'www.microsoft.com'

$script:NetworkSmbPort = 445

function Get-NetworkData {
    [CmdletBinding()]
    [OutputType([psobject])]
    param([hashtable]$Parameters = @{})

    $adapters = @()
    try {
        $adapters = @(Get-NetAdapter -ErrorAction Stop | Where-Object { $_.Status -eq 'Up' } | ForEach-Object {
            [pscustomobject]@{
                Name        = $_.Name
                Description = "$($_.InterfaceDescription)"
                LinkSpeed   = "$($_.LinkSpeed)"
            }
        })
    }
    catch { }

    # The signal is parsed out of an external tool's output, which is the one genuinely
    # localisation-exposed read in Gutcheck. An unparseable answer leaves the percentage
    # absent, which the Judge treats as nothing to say rather than as a bad signal.
    $wifiText   = ''
    $wifiSignal = $null
    try {
        $wifiText = (netsh wlan show interfaces 2>$null | Out-String)
        $match = [regex]::Match($wifiText, '(?m)^\s*Signal\s*:\s*(\d+)\s*%')
        if ($match.Success) { $wifiSignal = [int]$match.Groups[1].Value }
    }
    catch { }

    $gateway = $null
    try {
        $gateway = (Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction Stop |
            Sort-Object RouteMetric | Select-Object -First 1).NextHop
    }
    catch { }
    if ($gateway -eq '0.0.0.0') { $gateway = $null }

    $gatewayLatency = $null
    if ($gateway) { $gatewayLatency = Measure-Latency -Target $gateway -Count $script:NetworkGatewayPingCount }

    $internetLatency = Measure-Latency -Target $script:NetworkInternetTarget -Count $script:NetworkInternetPingCount

    $resolution = Resolve-HostAddress -HostName $script:NetworkDnsTarget

    $mappings = @()
    try {
        $mappings = @(Get-SmbMapping -ErrorAction Stop | ForEach-Object {
            $server = ("$($_.RemotePath)" -split '\\')[2]
            [pscustomobject]@{
                LocalPath  = "$($_.LocalPath)"
                RemotePath = "$($_.RemotePath)"
                Status     = "$($_.Status)"
                Server     = $server
                ConnectMs  = $(if ($server) { Measure-TcpConnect -HostName $server -Port $script:NetworkSmbPort } else { $null })
            }
        })
    }
    catch { }

    # Latency to every mapped file server, for the detail table: a share that feels slow
    # and a share that is slow are told apart here.
    $mappedLatency = @(
        $mappings | Where-Object { $_.Server } | Select-Object -ExpandProperty Server -Unique |
            ForEach-Object { Measure-Latency -Target $_ -Count $script:NetworkServerPingCount }
    )

    [pscustomobject]@{
        PSTypeName        = 'Gutcheck.Data.Network'
        Adapters          = $adapters
        WifiSignalPercent = $wifiSignal
        WifiInterfaceText = $wifiText
        GatewayAddress    = $gateway
        GatewayLatency    = $gatewayLatency
        InternetTarget    = $script:NetworkInternetTarget
        InternetLatency   = $internetLatency
        DnsTarget         = $script:NetworkDnsTarget
        DnsResolved       = $resolution.Resolved
        DnsMilliseconds   = $resolution.Milliseconds
        SmbMappings       = $mappings
        MappedLatency     = $mappedLatency
        # Which rights the readings were taken with. Not a judgement, and not the same
        # question as Finding.Privilege: the Judge needs it because an elevated session
        # cannot see the Technician's drive mappings, so an empty list means two things.
        Elevated          = (Get-CurrentPrivilege) -eq 'admin'
    }
}

function ConvertTo-NetworkFinding {
    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()]$Data,
        [hashtable]$Parameters = @{}
    )

    New-AdapterFinding         -Data $Data -Parameters $Parameters
    New-WifiSignalFinding      -Data $Data -Parameters $Parameters
    New-GatewayLatencyFinding  -Data $Data -Parameters $Parameters
    New-InternetLatencyFinding -Data $Data -Parameters $Parameters
    New-DnsResolutionFinding   -Data $Data -Parameters $Parameters
    New-MappedDriveFinding     -Data $Data -Parameters $Parameters
}

function ConvertTo-NetworkSection {
    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Data)

    $latency = @(
        Get-DataProperty $Data 'GatewayLatency'
        Get-DataProperty $Data 'InternetLatency'
        Get-DataProperty $Data 'MappedLatency'
    ) | Where-Object { $_ }

    New-Section -Title (Get-Text 'Title.Network.LatencyTestsNetwork') -Row (@($latency) | ForEach-Object {
        $_ | Select-Object Target, Sent, Lost, LossPercent, AverageMs, MaximumMs
    })

    $wifi = Get-DataProperty $Data 'WifiInterfaceText'
    if ($wifi) { New-Section -Title (Get-Text 'Title.Network.WiFiInterfaceNetsh') -Text $wifi }
}

function New-AdapterFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [hashtable]$Parameters)

    # Which descriptions name a tunnel rather than a wire. A parameter because the estate
    # a Customer runs decides it, and because a VPN nobody knew was up explains a great
    # deal about an application that only feels slow at one site.
    $vpnPattern = Get-Parameter $Parameters 'VpnAdapterPattern' `
        'VPN|Fortinet|AnyConnect|GlobalProtect|Wintun|TAP-|WireGuard|Juniper|Check Point|Sophos'

    $adapters = (Get-DataCollection $Data 'Adapters')
    if (-not $adapters.Count) {
        # The script said nothing here, which reads in the Report exactly like a machine
        # that was never asked. A machine with every adapter down explains itself.
        return New-Finding -Category Network -Check (Get-Text 'Check.Network.NetworkAdapters') -Severity WARN `
            -Value (Get-Text 'Value.Network.NoneUp') `
            -Hint (Get-Text 'Hint.Network.NoNetworkAdapterIsUp')
    }

    foreach ($adapter in $adapters) {
        $hint = ''
        if ("$($adapter.Description)" -match $vpnPattern) {
            $hint = (Get-Text 'Hint.Network.VpnAdapterActive')
        }
        New-Finding -Category Network -Check ((Get-Text 'Check.Network.Adapter') -f $adapter.Name) -Severity INFO `
            -Value ('{0} @ {1}' -f $adapter.Description, $adapter.LinkSpeed) -Hint $hint
    }
}

function New-WifiSignalFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [hashtable]$Parameters)

    $warnBelow = Get-Parameter $Parameters 'WifiSignalWarnBelowPercent' 70
    $failBelow = Get-Parameter $Parameters 'WifiSignalFailBelowPercent' 50

    $signal = ConvertTo-Number (Get-DataProperty $Data 'WifiSignalPercent')

    # Nothing at all when there is no reading, which is the one place Gutcheck stays
    # silent on purpose. A machine with no wireless adapter and a machine whose wireless
    # output did not parse are indistinguishable here, and inventing a Finding for either
    # would be inventing a fact. See #1, Out of Scope.
    if ($null -eq $signal) { return }

    New-Finding -Category Network -Check (Get-Text 'Check.Network.WiFiSignal') `
        -Severity (Get-SeverityBelow $signal $warnBelow $failBelow) -Value ('{0:N0} %' -f $signal) `
        -Hint (Get-Text 'Hint.Network.WeakWiFiSlowSync')
}

function New-GatewayLatencyFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [hashtable]$Parameters)

    $lossWarn = Get-Parameter $Parameters 'GatewayLossWarnPercent'  0
    $lossFail = Get-Parameter $Parameters 'GatewayLossFailPercent'  5
    $msWarn   = Get-Parameter $Parameters 'GatewayLatencyWarnMs'   10
    $msFail   = Get-Parameter $Parameters 'GatewayLatencyFailMs'   50

    $address = Get-DataProperty $Data 'GatewayAddress'
    $latency = Get-DataProperty $Data 'GatewayLatency'

    if (-not $address -or -not $latency) {
        return New-Finding -Category Network -Check (Get-Text 'Check.Network.DefaultGateway') -Severity WARN -Value (Get-Text 'Value.Shared.None') `
            -Hint (Get-Text 'Hint.Network.TheMachineHasNoDefault')
    }

    $loss = ConvertTo-Number (Get-DataProperty $latency 'LossPercent')
    $avg  = ConvertTo-Number (Get-DataProperty $latency 'AverageMs')
    $max  = ConvertTo-Number (Get-DataProperty $latency 'MaximumMs')

    if ($null -eq $avg) {
        # Every ping lost. On a gateway that is not a blocked ping, it is a broken link.
        return New-Finding -Category Network -Check (Get-Text 'Check.Network.GatewayLatency') -Severity FAIL `
            -Value ((Get-Text 'Value.Network.NoReplyWithLoss') -f $address, $loss) `
            -Hint (Get-Text 'Hint.Network.TheDefaultGatewayDidNot')
    }

    # Loss and latency are independent: whichever reads worse decides.
    $severity = Get-WorstSeverity (Get-Severity $loss $lossWarn $lossFail) (Get-Severity $avg $msWarn $msFail)

    New-Finding -Category Network -Check (Get-Text 'Check.Network.GatewayLatency') -Severity $severity `
        -Value ((Get-Text 'Value.Network.PingResult') -f $address, $avg, $max, $loss) `
        -Hint (Get-Text 'Hint.Network.UnstableLocalNetworkWiFi')
}

function New-InternetLatencyFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [hashtable]$Parameters)

    $target  = Get-DataProperty $Data 'InternetTarget'
    $latency = Get-DataProperty $Data 'InternetLatency'
    if (-not $latency) { return }

    $loss = ConvertTo-Number (Get-DataProperty $latency 'LossPercent')
    $avg  = ConvertTo-Number (Get-DataProperty $latency 'AverageMs')

    # Never graded. Plenty of Customer networks block ICMP to the internet entirely, and a
    # FAIL there would be a Finding about a firewall policy rather than about the machine.
    $value = $(if ($null -eq $avg) { "no reply (loss $loss %)" } else { (Get-Text 'Value.Network.LatencyWithLoss') -f $avg, $loss })
    New-Finding -Category Network -Check ((Get-Text 'Check.Network.InternetLatency') -f $target) -Severity INFO -Value $value
}

function New-DnsResolutionFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [hashtable]$Parameters)

    $warn = Get-Parameter $Parameters 'DnsResolutionWarnMs' 500
    $fail = Get-Parameter $Parameters 'DnsResolutionFailMs' ([double]::MaxValue)

    $target = Get-DataProperty $Data 'DnsTarget'
    $check  = 'DNS resolution ({0})' -f $target

    if (-not (Get-DataProperty $Data 'DnsResolved')) {
        return New-Finding -Category Network -Check $check -Severity FAIL -Value (Get-Text 'Value.Network.Failed') `
            -Hint (Get-Text 'Hint.Network.NoInternetNameResolution')
    }

    $ms = ConvertTo-Number (Get-DataProperty $Data 'DnsMilliseconds')
    if ($null -eq $ms) {
        return New-UnavailableFinding -Category Network -Check $check `
            -Hint (Get-Text 'Hint.Shared.NameResolvedNotTimed')
    }

    New-Finding -Category Network -Check $check -Severity (Get-Severity $ms $warn $fail) `
        -Value ('{0:N0} ms' -f $ms) `
        -Hint (Get-Text 'Hint.Network.SlowDNSDelaysEveryConnection')
}

function New-MappedDriveFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [hashtable]$Parameters)

    $mappings = (Get-DataCollection $Data 'SmbMappings')

    if (-not $mappings.Count) {
        # In the Technician's own session no mappings means no mappings, which is not
        # worth a line. Elevated it means something else entirely, and staying quiet would
        # let a Technician read an empty list as a machine with no network drives.
        if (Get-DataProperty $Data 'Elevated') {
            return New-Finding -Category Network -Check (Get-Text 'Check.Network.MappedDrives') -Severity INFO -Value (Get-Text 'Value.Network.NoneVisible') `
                -Hint (Get-Text 'Hint.Network.ElevatedSessionsDoNotSee')
        }
        return
    }

    foreach ($mapping in $mappings) {
        $connect  = ConvertTo-Number $mapping.ConnectMs
        $statusOk = "$($mapping.Status)" -eq 'OK'

        $severity = 'OK'
        if (-not $statusOk -or $null -eq $connect) { $severity = 'WARN' }

        New-Finding -Category Network -Check ((Get-Text 'Check.Network.MappedDrive') -f $mapping.LocalPath) -Severity $severity `
            -Value ((Get-Text 'Value.Network.MappingStatus') -f $mapping.RemotePath, $mapping.Status,
                    $(if ($null -eq $connect) { (Get-Text 'Value.Network.Failed') } else { '{0} ms' -f $connect })) `
            -Hint (Get-Text 'Hint.Network.AppsStartedFromANetwork')
    }
}