Private/Measurement.ps1

# The Measurement primitive.
#
# A Measurement is one number a Check measured on the way from the Target Machine to
# something it talks to: the gateway's ping, a DNS server's answer, the SMB connect to the
# Server a mapped drive lands on. It is what the Report draws the network from, beside the
# Findings that judge the same numbers in words. See ADR-0009.
#
# A Judge makes it, not a Gatherer (ADR-0002): it names the Finding it belongs to, and only
# the Judge knows which Finding that is. Like a Finding it is a value and records nothing;
# a Judge returns it among its Findings and the Run tells the two apart by type.
#
# Every field a view sorts or groups by is a closed vocabulary, for the reason Category is
# one: a misspelt Metric in one Kind would otherwise be a bar of its own in the ranking,
# and nobody would notice until a Customer's Report showed it.

# What the measured thing is to the Target Machine. Machine and Adapter are the machine's
# own end of the way: the link speed and the Wi-Fi signal.
$script:MeasurementRoleValues = @(
    'DomainController', 'DnsServer', 'DhcpServer', 'FileServer', 'DfsTarget', 'PrintServer', 'AppServer',
    'Gateway', 'InternetProbe', 'HttpsService', 'Website', 'WebsiteHost', 'TimeSource', 'Machine', 'Adapter'
)

# Where on the way it is: the local network, the internet, or behind a VPN. Empty where
# the Measurement is no address - an adapter - or the address is not known.
$script:MeasurementZoneValues = @('Lan', 'Wan', 'Vpn', '')

# What was measured, each in the one unit it is always written in. The unit is the
# Metric's and not the Judge's to give, so that the ranking never compares milliseconds
# with seconds because two Kinds chose differently.
$script:MeasurementMetricUnits = [ordered]@{
    Ping            = 'ms'
    PingMax         = 'ms'
    Loss            = '%'
    TcpConnect      = 'ms'
    Dns             = 'ms'
    Ldap            = 'ms'
    Kerberos        = 'ms'
    Smb             = 'ms'
    Tls             = 'ms'
    Response        = 'ms'
    FirstByte       = 'ms'
    LinkSpeed       = 'Mbit/s'
    WifiSignal      = '%'
    ClockOffset     = 's'
    ConnectFailures = 'count'
}

# What became of the measuring. Only Measured and Failed may carry a number: what was not
# measured, answered from a cache or blocked has none, and a 0 there would be drawn as the
# fastest bar in the ranking.
$script:MeasurementStateValues = @('Measured', 'Cached', 'NotMeasured', 'Failed', 'Blocked')
$script:MeasurementStatesWithoutValue = @('Cached', 'NotMeasured', 'Blocked')
# And only what did not come about carries an Error: why it failed, was blocked, or was not
# measured at all - a name that did not resolve was never connected to.
$script:MeasurementStatesWithoutError = @('Measured', 'Cached')

# Which adapter descriptions name a tunnel rather than a wire. The default of the Network
# Kind's VpnAdapterPattern parameter, and what a route's adapter is read by.
$script:MeasurementVpnAdapterPattern = 'VPN|Fortinet|AnyConnect|GlobalProtect|Wintun|TAP-|WireGuard|Juniper|Check Point|Sophos'

# The interface type Windows gives a dial-up connection (IANA ifType ppp): the VPN Windows
# brings itself, whose adapter is described as a "WAN Miniport" and names no VPN.
$script:MeasurementPppInterfaceType = 23

# The addresses that are never on the internet: RFC 1918, the shared space of RFC 6598,
# link-local, loopback, and for IPv6 unique local, link-local, loopback and the site-local
# addresses Windows puts in as DNS servers where nobody gave IPv6 any (fec0:0:0:ffff::1 to 3,
# of a range long withdrawn, and never routed on the internet). The IPv4 ones
# as their bytes: written as addresses, they are what the package check refuses to find in
# the code - an address out of somebody's own network.
$script:MeasurementPrivateRanges = @(
    @([byte[]](10, 0, 0, 0), 8), @([byte[]](172, 16, 0, 0), 12), @([byte[]](192, 168, 0, 0), 16),
    @([byte[]](100, 64, 0, 0), 10), @([byte[]](169, 254, 0, 0), 16), @([byte[]](127, 0, 0, 0), 8),
    @('fc00::', 7), @('fe80::', 10), @('fec0::', 10), @('::1', 128)
)

function Get-MeasurementVocabularyValue {
    # One value of a closed vocabulary in its canonical casing, or a throw that says what was
    # wrong and what was allowed. Where a Judge is written, not at a Customer Site.
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)][string]$Field,
        [AllowNull()][AllowEmptyString()][string]$Value,
        [Parameter(Mandatory)][AllowEmptyString()][string[]]$Permitted
    )

    $found = @($Permitted | Where-Object { $_ -eq "$Value" } | Select-Object -First 1)
    if (-not $found.Count) {
        throw ("'{0}' is not a Measurement {1}. Permitted: {2}." -f $Value, $Field, (($Permitted | Where-Object { $_ }) -join ', '))
    }
    $found[0]
}

function New-Measurement {
    <#
    .SYNOPSIS
        One number a Check measured, as a view of the Report draws it. Pure.
    .DESCRIPTION
        Validated here, like a Finding: an unknown Role, Zone, Metric or State throws where
        the Judge is written. A Measured one needs a Value; one that was not measured,
        answered from a cache or blocked must not have one.
    .PARAMETER Subject
        The host as it was measured: a name or an address, the adapter's name. Empty for the
        machine itself.
    .PARAMETER Address
        The address the Subject resolved to, where it is known.
    .PARAMETER Value
        The number, in the Metric's unit. Nothing where nothing was measured.
    .PARAMETER StartMs
        Where a step begins on the time axis of the waterfall, from the start of the load.
    .PARAMETER Samples
        How many tries the Value stands for: thirty pings, one connect.
    .PARAMETER PrefixLength
        On an Adapter's Measurement, with its own Address: the subnet the machine is in on
        that adapter, as the Weg groups its Servers by. Only with an Address.
    .PARAMETER ErrorText
        What went wrong, in words, for a Failed or Blocked one; for a NotMeasured one, why
        it was not measured ("Name nicht aufloesbar"), where a Judge knows. A Measured or
        Cached one has none: nothing went wrong, and a view would draw it as a failure.
    .PARAMETER Proxy
        The proxy's address, where the measurement went through one: then it measured the
        proxy, and says so.
    .PARAMETER Group
        What the Measurement belongs to as a whole, where it is one step of several: one site,
        one HTTPS endpoint. The waterfall draws one card per Group. Empty where it stands alone.
    .PARAMETER Finding
        The Finding this number belongs to, whose Severity a view colours it with: the
        Finding itself, or its Id as it arrived from the Elevated Part. Nothing where no
        Finding judges it, and a view then draws it grey.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()][AllowEmptyString()][string]$Subject = '',
        [AllowNull()][AllowEmptyString()][string]$Address = '',
        [Parameter(Mandatory)][AllowEmptyString()][string]$Role,
        [AllowNull()][AllowEmptyString()][string]$Zone = '',
        [Parameter(Mandatory)][AllowEmptyString()][string]$Metric,
        [AllowNull()]$Value,
        [AllowNull()]$StartMs,
        [AllowNull()]$Samples,
        [AllowNull()]$Port,
        [AllowNull()]$PrefixLength,
        [AllowNull()][AllowEmptyString()][string]$State = 'Measured',
        [AllowNull()][AllowEmptyString()][string]$ErrorText = '',
        [AllowNull()][AllowEmptyString()][string]$Proxy = '',
        [AllowNull()]$Finding,
        [AllowNull()][AllowEmptyString()][string]$Group = ''
    )

    $roleValue   = Get-MeasurementVocabularyValue -Field Role   -Value $Role   -Permitted $script:MeasurementRoleValues
    $zoneValue   = Get-MeasurementVocabularyValue -Field Zone   -Value $Zone   -Permitted $script:MeasurementZoneValues
    $metricValue = Get-MeasurementVocabularyValue -Field Metric -Value $Metric -Permitted @($script:MeasurementMetricUnits.Keys)
    $stateValue  = Get-MeasurementVocabularyValue -Field State  -Value $State  -Permitted $script:MeasurementStateValues

    # A number or nothing. Text that is no number is refused rather than read as nothing:
    # "12,4 ms" handed in by a Judge is a Judge to fix, not a value not measured.
    $number = $null
    if ($null -ne $Value -and "$Value" -ne '') {
        $number = ConvertTo-Number $Value
        if ($null -eq $number) { throw ("'{0}' is not a number. A Measurement's Value is a number in {1}, or nothing." -f $Value, $script:MeasurementMetricUnits[$metricValue]) }
    }
    if ($stateValue -eq 'Measured' -and $null -eq $number) {
        throw ("A Measured {0} needs a Value. One that was not measured is NotMeasured." -f $metricValue)
    }
    if ($stateValue -in $script:MeasurementStatesWithoutValue -and $null -ne $number) {
        throw ("A {0} {1} carries no Value: a number there would be drawn as a reading." -f $stateValue, $metricValue)
    }
    if ($stateValue -in $script:MeasurementStatesWithoutError -and "$ErrorText".Trim()) {
        throw ("A {0} {1} carries no Error: nothing went wrong, and a view would draw it as a failure." -f $stateValue, $metricValue)
    }

    $optional = @{}
    foreach ($pair in @(@('StartMs', $StartMs), @('Samples', $Samples), @('Port', $Port), @('PrefixLength', $PrefixLength))) {
        $optional[$pair[0]] = $null
        if ($null -eq $pair[1] -or "$($pair[1])" -eq '') { continue }
        $read = ConvertTo-Number $pair[1]
        if ($null -eq $read) { throw ("'{0}' is not a number for {1}." -f $pair[1], $pair[0]) }
        $optional[$pair[0]] = $read
    }
    if ($null -ne $optional.Port -and ($optional.Port -lt 1 -or $optional.Port -gt 65535 -or $optional.Port -ne [math]::Floor($optional.Port))) {
        throw ("'{0}' is not a port." -f $Port)
    }
    # A subnet is an address and its prefix length: one without the other says nothing.
    if ($null -ne $optional.PrefixLength) {
        $own = ConvertTo-MeasurementAddress -Address $Address
        if ($null -eq $own) { throw ("A prefix length needs the Address it belongs to; '{0}' is none." -f $Address) }
        $bits = $own.GetAddressBytes().Length * 8
        if ($optional.PrefixLength -lt 0 -or $optional.PrefixLength -gt $bits -or $optional.PrefixLength -ne [math]::Floor($optional.PrefixLength)) {
            throw ("'{0}' is not a prefix length for {1}." -f $PrefixLength, $Address)
        }
    }

    $findingId = ConvertTo-FindingId -Finding $Finding -Of 'a Measurement'

    [pscustomobject]@{
        PSTypeName = 'Gutcheck.Measurement'
        Subject    = "$Subject".Trim()
        Address    = "$Address".Trim()
        Role       = $roleValue
        Zone       = $zoneValue
        Metric     = $metricValue
        Value      = $number
        Unit       = $script:MeasurementMetricUnits[$metricValue]
        StartMs    = $optional.StartMs
        Samples    = $(if ($null -ne $optional.Samples) { [int]$optional.Samples } else { $null })
        Port       = $(if ($null -ne $optional.Port) { [int]$optional.Port } else { $null })
        PrefixLength = $(if ($null -ne $optional.PrefixLength) { [int]$optional.PrefixLength } else { $null })
        State      = $stateValue
        Error      = "$ErrorText".Trim()
        Proxy      = "$Proxy".Trim()
        Finding    = $findingId
        Group      = "$Group".Trim()
        # Stamped on by the Run, as on a Finding. See Set-CheckOrigin.
        CheckName  = ''
        App        = ''
    }
}

function ConvertTo-FindingId {
    <#
    .SYNOPSIS
        The Id a record names its Finding by: the Finding's own, or the Id itself as it
        arrived from the Elevated Part. Empty where no Finding judges it. Pure.
    .DESCRIPTION
        A Finding object exists only in the process that made it, and an Id survives
        CliXML, a CSV and a JSON block alike. A Measurement and a Timed Event name their
        Finding this way (ADR-0009, ADR-0010).
    .PARAMETER Of
        What names the Finding, for the throw: "a Measurement".
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$Finding, [string]$Of = 'a record')

    if ($Finding -is [string]) { return $Finding.Trim() }
    if ($null -eq $Finding) { return '' }
    $id = "$(Get-DataProperty $Finding 'Id')".Trim()
    if (-not $id) { throw ('The Finding {0} belongs to carries no Id. Hand it the Finding New-Finding made.' -f $Of) }
    $id
}

function ConvertTo-MeasurementAddress {
    # An address as .NET reads it, without the zone of a link-local one; $null for a name,
    # for nothing, and for a number that is no dotted address.
    [CmdletBinding()]
    [OutputType([System.Net.IPAddress])]
    param([AllowNull()][AllowEmptyString()][string]$Address)

    $text = ("$Address".Trim() -split '%')[0]
    if (-not $text) { return $null }
    # "1" parses as 0.0.0.1, which nobody measured.
    if ($text -notmatch ':' -and $text -notmatch '^\d{1,3}(\.\d{1,3}){3}$') { return $null }
    $parsed = $null
    if ([System.Net.IPAddress]::TryParse($text, [ref]$parsed)) { return $parsed }
    $null
}

function Test-MeasurementAddressInPrefix {
    # Whether an address lies in a network given by an address and a prefix length. Pure.
    [CmdletBinding()]
    [OutputType([bool])]
    param([Parameter(Mandatory)][System.Net.IPAddress]$Address, [AllowNull()]$Network, [AllowNull()]$PrefixLength)

    $net = $Network
    if ($net -is [byte[]]) { $net = [System.Net.IPAddress]::new($net) }
    elseif ($net -isnot [System.Net.IPAddress]) { $net = ConvertTo-MeasurementAddress -Address "$Network" }
    $length = ConvertTo-Number $PrefixLength
    if ($null -eq $net -or $null -eq $length -or $net.AddressFamily -ne $Address.AddressFamily) { return $false }

    $a = $Address.GetAddressBytes()
    $b = $net.GetAddressBytes()
    $bits = [int][math]::Min($length, $a.Length * 8)
    for ($i = 0; $i -lt $bits; $i++) {
        $mask = 0x80 -shr ($i % 8)
        if (($a[[int][math]::Floor($i / 8)] -band $mask) -ne ($b[[int][math]::Floor($i / 8)] -band $mask)) { return $false }
    }
    $true
}

function Get-MeasurementNetworkAddress {
    # The network an address lies in under a prefix length: the address with every bit past
    # the prefix cleared. 192.0.2.17 and 24 are 192.0.2.0. Pure.
    [CmdletBinding()]
    [OutputType([System.Net.IPAddress])]
    param([Parameter(Mandatory)][System.Net.IPAddress]$Address, [Parameter(Mandatory)][int]$PrefixLength)

    $bytes = $Address.GetAddressBytes()
    for ($i = 0; $i -lt $bytes.Length; $i++) {
        $keep = [math]::Max(0, [math]::Min(8, $PrefixLength - $i * 8))
        $bytes[$i] = $bytes[$i] -band ((0xFF -shl (8 - $keep)) -band 0xFF)
    }
    [System.Net.IPAddress]::new($bytes)
}

function Get-MeasurementRouteKind {
    <#
    .SYNOPSIS
        Whether the route Windows would take to an address leaves through a VPN adapter:
        Vpn, Direct, or nothing where no route was read. Pure.
    .DESCRIPTION
        By the adapter the route leaves through, as the Gatherer read it: its description,
        by the same pattern the Network Check names a tunnel by, or its type, for the VPN of
        Windows itself, whose adapter is a "WAN Miniport". Never by the name of what was
        measured.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$Route, [AllowNull()][AllowEmptyString()][string]$VpnPattern = $script:MeasurementVpnAdapterPattern)

    if ($null -eq $Route) { return '' }
    if (-not $VpnPattern) { $VpnPattern = $script:MeasurementVpnAdapterPattern }
    if ((ConvertTo-Number (Get-DataProperty $Route 'InterfaceType')) -eq $script:MeasurementPppInterfaceType) { return 'Vpn' }
    if ("$(Get-DataProperty $Route 'InterfaceDescription')" -match $VpnPattern) { return 'Vpn' }
    'Direct'
}

function Get-MeasurementZone {
    <#
    .SYNOPSIS
        Where on the way an address lies: Lan, Vpn, Wan, or nothing where there is no
        address. Pure.
    .DESCRIPTION
        As decided on ticket #103, in this order:
 
        1. Lan, next door: the address lies in one of the machine's own subnets, by each
           adapter's address and prefix length.
        2. Vpn: the route Windows would take leaves through a VPN adapter - for a private
           address as for a public one: a 10.x address may be the head office through the
           tunnel.
        3. Lan, behind a router: a private address otherwise.
        4. Wan: a public address otherwise.
 
        Never from a name, and never from the latency. A measurement through a proxy has no
        Zone: the machine resolved and connected to the proxy, never to the service, so it
        has no address of the service's - and the proxy's would put the service where the
        proxy is. Such a Kind gives no address, and says which proxy in the Measurement.
    .PARAMETER Prefix
        The machine's own addresses, each with Address and PrefixLength.
    .PARAMETER RouteKind
        What Get-MeasurementRouteKind said of the route to this address.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [AllowNull()][AllowEmptyString()][string]$Address,
        [AllowNull()][AllowEmptyCollection()]$Prefix,
        [AllowNull()][AllowEmptyString()][string]$RouteKind
    )

    $parsed = ConvertTo-MeasurementAddress -Address $Address
    if ($null -eq $parsed) { return '' }

    foreach ($own in @($Prefix | Where-Object { $_ })) {
        if (Test-MeasurementAddressInPrefix -Address $parsed -Network (Get-DataProperty $own 'Address') -PrefixLength (Get-DataProperty $own 'PrefixLength')) {
            return 'Lan'
        }
    }
    if ($RouteKind -eq 'Vpn') { return 'Vpn' }
    foreach ($range in $script:MeasurementPrivateRanges) {
        if (Test-MeasurementAddressInPrefix -Address $parsed -Network $range[0] -PrefixLength $range[1]) { return 'Lan' }
    }
    'Wan'
}

function Get-GatheredAddressZone {
    <#
    .SYNOPSIS
        The Zone of an address a Check measured, from what its Gatherer read beside it: the
        machine's own subnets (Prefixes) and the route to each measured address (Routes).
        Pure; see Get-MeasurementZone.
    .DESCRIPTION
        For every Kind whose Gatherer records Prefixes and Routes as Get-OwnPrefix and
        Get-AddressRoute read them. Data without them is judged by the address alone.
    .PARAMETER Route
        The route to this address, where the Gatherer kept it beside the Server it measured
        rather than among Routes. Given, it is taken as it is: nothing where none was read.
    .PARAMETER VpnPattern
        The Kind's VpnAdapterPattern parameter. Each Kind reads it with Get-Parameter
        itself, so that the reference of its parameters lists it.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [AllowNull()]$Data,
        [AllowNull()][AllowEmptyString()][string]$Address,
        [AllowNull()][AllowEmptyString()][string]$VpnPattern,
        [AllowNull()]$Route
    )

    if (-not $PSBoundParameters.ContainsKey('Route')) {
        $Route = @((Get-DataCollection $Data 'Routes') | Where-Object { "$(Get-DataProperty $_ 'Address')" -eq $Address }) | Select-Object -First 1
    }
    Get-MeasurementZone -Address $Address -Prefix (Get-DataCollection $Data 'Prefixes') `
        -RouteKind (Get-MeasurementRouteKind -Route $Route -VpnPattern $VpnPattern)
}

function ConvertTo-DnsMeasurement {
    <#
    .SYNOPSIS
        The resolution of a name that resolved, as a Measurement: from the cache, not
        timed, or the time it took. Pure.
    .DESCRIPTION
        A name the DNS client of Windows answered from its cache is Cached and has no time:
        it says nothing about a DNS server. Where the Gatherer could not ask the cache, the
        time stands as measured.
    .PARAMETER Common
        What every Measurement of this Server carries: Subject, Address, Role, Zone.
    .PARAMETER Cached
        What Test-DnsClientCacheEntry said: $true, $false, or nothing where it could not ask.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([Parameter(Mandatory)][hashtable]$Common, [AllowNull()]$Cached, [AllowNull()]$Milliseconds, [AllowNull()]$Finding)

    $ms = ConvertTo-Number $Milliseconds
    if ($Cached -eq $true) { return New-Measurement @Common -Metric Dns -State Cached -Finding $Finding }
    if ($null -eq $ms) { return New-Measurement @Common -Metric Dns -State NotMeasured -Finding $Finding }
    New-Measurement @Common -Metric Dns -Samples 1 -Value $ms -Finding $Finding
}

function New-UnresolvedMeasurement {
    <#
    .SYNOPSIS
        A Server whose name did not resolve, as its Measurements: the resolution failed,
        and each port and the pings were not measured. Pure.
    .DESCRIPTION
        Nothing else is asked of a name that does not exist. With no address there is no
        Zone either, and all of them belong to the Finding that says the name did not
        resolve.
    .PARAMETER Common
        What every Measurement of this Server carries: Subject, Address, Role, Zone.
    .PARAMETER Port
        The ports that would have been connected to.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([Parameter(Mandatory)][hashtable]$Common, [AllowNull()][AllowEmptyCollection()]$Port, [AllowNull()]$Finding)

    New-Measurement @Common -Metric Dns -Samples 1 -State Failed -ErrorText (Get-Text 'Measurement.Error.NotResolved') -Finding $Finding
    foreach ($number in @($Port | Where-Object { $_ })) { New-Measurement @Common -Metric TcpConnect -Port $number -State NotMeasured -Finding $Finding }
    foreach ($metric in 'Ping', 'PingMax', 'Loss') { New-Measurement @Common -Metric $metric -State NotMeasured -Finding $Finding }
}

function ConvertTo-TcpConnectMeasurement {
    <#
    .SYNOPSIS
        The connects to one port as a Measurement: their average where any came about, a
        failure where none did. Pure.
    .PARAMETER Common
        What every Measurement of this Server carries: Subject, Address, Role, Zone, and the
        Port and Finding where they are the same for all.
    .PARAMETER Result
        The connects as Measure-TcpConnect's callers record them: Attempts, Successes and
        AverageMs.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([Parameter(Mandatory)][hashtable]$Common, [AllowNull()]$Result)

    $average  = ConvertTo-Number (Get-DataProperty $Result 'AverageMs')
    $attempts = Get-DataProperty $Result 'Attempts'
    if ((ConvertTo-Number (Get-DataProperty $Result 'Successes')) -gt 0 -and $null -ne $average) {
        return New-Measurement @Common -Metric TcpConnect -Samples $attempts -Value $average
    }
    New-Measurement @Common -Metric TcpConnect -Samples $attempts -State Failed -ErrorText (Get-Text 'Measurement.Error.NoConnection')
}