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 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()][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))) { $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) } $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 }) 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-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') } |