Private/Connectivity.ps1
|
#Requires -Version 5.1 # Connectivity matrix (docs/07-connectivity-validation.md, endpoints from docs/17-network-requirements.md). # # * One engine: network probes are batched through Invoke-DDNetProbeBatch (5 s per probe, 60 s total, parallel), # local checks are computed from facts already collected. Test-DOConnectivity and the inventory/telemetry # paths all call Get-DDConnectivityMatrix. # * Results: pass | fail | warn | not-applicable | not-collected. A check that was not run is `not-collected`, # never a pass. Applicability is computed from the device's actual download mode. # * Network targets come from endpoint health (Endpoints.ps1, Data/endpoints.json): the cloud, content, Teredo and # Connected Cache checks below are read from those results, so each host is probed once, on a host the device used # or one Microsoft documents, never a guessed one. Only the peer checks probe here. Set-StrictMode -Version Latest $script:DDTeredoHost = 'win1910.ipv6.microsoft.com' # documented (CFG #firewall); also manifest entry teredo-do function New-DDCheck { [CmdletBinding()] param( [Parameter(Mandatory)][string] $CheckId, [string] $Target = '', [ValidateSet('dns', 'tcp', 'http', 'https', 'udp', 'local')][string] $Protocol = 'local', $Port = $null, [Parameter(Mandatory)][ValidateSet('pass', 'fail', 'warn', 'not-applicable', 'not-collected')][string] $Result, $LatencyMs = $null, [Parameter(Mandatory)][string] $Detail, [string] $Evidence ) $o = [ordered]@{ checkId = $CheckId; target = $Target; protocol = $Protocol; port = $Port; result = $Result; latencyMs = $LatencyMs; detail = $Detail } if ($Evidence) { $e = Protect-DDLogText -Text $Evidence $o['evidence'] = $e.Substring(0, [math]::Min(512, $e.Length)) } $o } function Get-DDEffectiveValue { [CmdletBinding()] param($Effective, [Parameter(Mandatory)][string] $Name) if ($null -ne $Effective -and $Effective.Contains($Name)) { return $Effective[$Name]['value'] } $null } # NetTeredoConfiguration.Type in the NetworkTransition module (MSFT_NetTeredoConfiguration.cdxml). $script:DDTeredoTypes = @{ 0 = 'default'; 1 = 'relay'; 2 = 'client'; 3 = 'server'; 4 = 'disabled'; 5 = 'automatic'; 6 = 'enterpriseclient'; 7 = 'natawareclient' } function ConvertTo-DDTeredoWord { # One Teredo state or error word, lower case: "probe(primary server)" -> probe, "Qualified" -> qualified; none -> $null. [CmdletBinding()] param([AllowNull()][AllowEmptyString()][string] $Text) if ([string]::IsNullOrWhiteSpace($Text)) { return $null } $w = ([regex]::Match($Text.Trim(), '^[A-Za-z_-]+')).Value.ToLowerInvariant() if (-not $w -or $w -eq 'none') { return $null } $w } function ConvertFrom-DDTeredoState { <# .SYNOPSIS Teredo facts from `netsh interface teredo show state` (English output only): @{ Type; State; Error; ServerName; Source = 'netsh' }, or $null when the text has no Type line. State is the operational state (qualified, dormant, probe, offline), not the configured Type; $null when the text has no State line. #> [CmdletBinding()] param([AllowEmptyCollection()][string[]] $Lines) $text = @($Lines) -join "`n" $line = { param($name) $m = [regex]::Match($text, "(?im)^\s*$name\s*:\s*(.*?)\s*$"); if ($m.Success) { $m.Groups[1].Value } else { $null } } $type = & $line 'Type' if (-not $type) { return $null } $state = & $line 'State'; $err = & $line 'Error'; $server = & $line 'Server Name' $errText = $(if ($err -and $err.Trim() -ne 'none') { $err.Trim() } else { $null }) [pscustomobject]@{ Type = ([regex]::Match($type, '^\S+')).Value.ToLowerInvariant(); State = (ConvertTo-DDTeredoWord $state); Error = $errText ServerName = $(if ($server) { $server.Trim().TrimEnd('.').ToLowerInvariant() } else { $null }); Source = 'netsh' } } function ConvertFrom-DDTeredoRaw { # Teredo facts from the Teredo classes (Get-DDTeredoStateRaw), shaped as ConvertFrom-DDTeredoState's. No state object is # Microsoft's "Teredo is not active": State 'inactive'. [CmdletBinding()] param($Raw) if ($null -eq $Raw) { return $null } $type = $null if ($null -ne $Raw.Type) { $n = 0; if ([int]::TryParse([string]$Raw.Type, [ref]$n) -and $script:DDTeredoTypes.ContainsKey($n)) { $type = $script:DDTeredoTypes[$n] } else { $type = ([string]$Raw.Type).ToLowerInvariant() } } $state = $(if ($Raw.StatePresent) { ConvertTo-DDTeredoWord ([string]$Raw.State) } else { 'inactive' }) $err = $(if ($Raw.StatePresent -and $Raw.Error -and ([string]$Raw.Error).Trim() -ne 'none') { ([string]$Raw.Error).Trim() } else { $null }) $server = Get-DDProp $Raw 'ServerName' [pscustomobject]@{ Type = $type; State = $state; Error = $err; ServerName = $(if ($server) { ([string]$server).Trim().TrimEnd('.').ToLowerInvariant() } else { $null }); Source = 'cim' } } function Get-DDTeredoFacts { # The Teredo classes first (language independent), netsh when they cannot be read. $null, with notCollected, when # neither can. [CmdletBinding()] param([Parameter(Mandatory)] $Context) $cim = $null try { $cim = ConvertFrom-DDTeredoRaw -Raw (Get-DDTeredoStateRaw) } catch { Write-Verbose "Teredo classes not read ($($_.Exception.GetType().Name)); trying netsh" } if ($null -ne $cim -and $cim.Type) { return $cim } Invoke-DDStep -Context $Context -Field 'connectivity.teredo-state' -Script { $t = ConvertFrom-DDTeredoState -Lines (Get-DDTeredoStateText) if ($null -eq $t) { throw [System.FormatException]::new('netsh interface teredo show state gave no Type line (its output is read in English only)') } $t } } # The matrix is capped (the inventory schema allows 128 results): 88 checks plus up to 40 endpoint-health rows # (MAX_ENDPOINT_RESULTS in packages/schema). $script:DDMaxMatrixChecks = 88 $script:DDMaxEndpointRows = 40 function Select-DDMatrixRows { <# .SYNOPSIS At most -Max of the checks, none of the one-per-check results lost to the per-host rows (docs/27 3.3). Priority order: the first row of every checkId (health, local and prerequisite checks, and one row per per-host group) in matrix order, then the further per-host rows (more peers, content hosts, cache hosts) in matrix order until the cap. The result keeps matrix order. Dropped is how many rows did not fit. Pure. #> [CmdletBinding()] param([AllowEmptyCollection()] $Checks = @(), [int] $Max = $script:DDMaxMatrixChecks) $all = @($Checks) $seen = New-Object 'System.Collections.Generic.HashSet[string]' $primary = New-Object 'System.Collections.Generic.List[int]' $extra = New-Object 'System.Collections.Generic.List[int]' for ($i = 0; $i -lt $all.Count; $i++) { if ($seen.Add([string]$all[$i]['checkId'])) { $primary.Add($i) } else { $extra.Add($i) } } $take = New-Object 'System.Collections.Generic.List[int]' foreach ($i in $primary) { if ($take.Count -lt $Max) { $take.Add($i) } } foreach ($i in $extra) { if ($take.Count -lt $Max) { $take.Add($i) } } $take.Sort() $rows = New-Object 'System.Collections.Generic.List[object]' foreach ($i in $take) { $rows.Add($all[$i]) } [pscustomobject]@{ Rows = $rows.ToArray(); Dropped = ($all.Count - $rows.Count) } } function Get-DDConnectivityMatrix { <# .SYNOPSIS Build the full connectivity matrix from probes and local facts. #> [CmdletBinding()] param( [Parameter(Mandatory)] $Context, $DoEffective, $Network, [AllowEmptyCollection()] $FirewallRules = @(), [AllowEmptyCollection()] $ObservedStatus = @(), [AllowEmptyCollection()][string[]] $PeerAddresses = @(), $DoSvc, $RamGB, # Capacity of the drive the DO cache is on, in GB (the system drive when the cache drive's size is unknown), and that # drive's name for the text. The disk-size check follows the cache drive (docs/27 3.2). [Alias('SystemDiskGB')] $DiskGB, [string] $DiskDrive = '', $Vitals, [AllowEmptyCollection()] $EndpointResults = @(), $FirewallProfiles, [switch] $SkipProbes, [int] $ProbeTimeoutMs = 5000, [int] $TotalTimeoutMs = 60000, # Delivery Optimization health (Health.ps1): the DO geo answer, the cache server DO will use, the DHCP facts, the # cache folder, whether DoSvc itself listens on TCP 7680 ($null: not read), the active power plan. $GeoAnswer, $CacheResolution, $Dhcp, $CacheFolder, $DoSvcListening = $null, [string] $PowerPlan, # Whether DoSvc's own TCP table holds an established peer connection (TCP 7680 at either end); $null: not read. $DoSvcPeerConnection = $null ) $checks = New-Object 'System.Collections.Generic.List[object]' $mode = Get-DDEffectiveValue -Effective $DoEffective -Name 'DODownloadMode' $modeNum = $null; if ($null -ne $mode) { $n = 0; if ([int]::TryParse([string]$mode, [ref]$n)) { $modeNum = $n } } $peeringOff = ($null -ne $modeNum -and $modeNum -in 0, 99, 100) $peering = ($null -ne $modeNum -and $modeNum -in 1, 2, 3) $modeText = $(if ($null -ne $modeNum) { "DODownloadMode=$modeNum" } else { 'DODownloadMode not determined' }) $skipDetail = 'skipped by -SkipConnectivityChecks' # ---- network probes -------------------------------------------------------------------------------------- $specs = New-Object 'System.Collections.Generic.List[object]' $addSpec = { param($kind, $target, $port, $tag) $specs.Add([pscustomobject]@{ Kind = $kind; Target = $target; Port = $port; Tag = $tag }) } $cacheHost = [string](Get-DDEffectiveValue -Effective $DoEffective -Name 'DOCacheHost') $cacheHosts = @() if ($cacheHost) { $cacheHosts = @($cacheHost.Split(',', [System.StringSplitOptions]::RemoveEmptyEntries) | ForEach-Object { $_.Trim() } | Select-Object -First 2) } $peers = @($PeerAddresses | Select-Object -First 3) if (-not $SkipProbes -and $peering) { for ($i = 0; $i -lt $peers.Count; $i++) { & $addSpec 'tcp' $peers[$i] 7680 "peer:$i" } } $out = @{} $peerProbeFailed = $false if ($specs.Count -gt 0) { # Inside a step like every other native entry point: where run-time compilation is refused (Constrained Language # Mode, application control) the peer probes are not collected and the rest of the matrix still runs. $res = @(Invoke-DDStep -Context $Context -Field 'connectivity.peers' -Default @() -Script { Invoke-DDNetProbeBatch -Specs $specs.ToArray() -ProbeTimeoutMs $ProbeTimeoutMs -TotalTimeoutMs $TotalTimeoutMs }) $peerProbeFailed = $Context.NotCollected.Exists([Predicate[object]] { param($e) $e['field'] -eq 'connectivity.peers' }) foreach ($r in $res) { $out[[string]$r.Tag] = $r } } $probe = { param($tag) if ($out.ContainsKey($tag)) { $out[$tag] } else { $null } } $fromProbe = { param($r, $okDetail, $failPrefix) if ($null -eq $r -and $peerProbeFailed) { return @{ Result = 'not-collected'; Detail = 'The peer probe could not run on this device (see notCollected: connectivity.peers)'; Ms = $null } } if ($null -eq $r) { return @{ Result = 'not-collected'; Detail = $skipDetail; Ms = $null } } if ($r.Ok) { return @{ Result = 'pass'; Detail = $okDetail; Ms = [double]$r.Ms } } if ($r.TimedOut) { return @{ Result = 'fail'; Detail = "$failPrefix timed out after $ProbeTimeoutMs ms (black-holed or filtered)"; Ms = [double]$r.Ms } } @{ Result = 'fail'; Detail = "$failPrefix failed: $($r.Error)"; Ms = [double]$r.Ms } } # ---- cloud service, content and metadata: read from endpoint health ---------------------------------------- $ep = @($EndpointResults | Where-Object { $_ -is [System.Collections.IDictionary] -and $_['endpoint'] }) $rowsOf = { param([string] $id) @($ep | Where-Object { $_['endpoint']['id'] -eq $id -and $_['endpoint']['basis'] -notin 'not-testable', 'not-applicable' }) } $noEp = $(if ($SkipProbes) { $skipDetail } else { 'endpoint health did not run' }) # The handshake completed even when the certificate was then judged (inspection, untrusted chain, wrong name). $handshake = { param($r) $r['endpoint']['tls'] -eq 'pass' -or ($r['endpoint']['err'] -and [string]$r['endpoint']['err']['code'] -like 'tls-*' -and [string]$r['endpoint']['err']['code'] -ne 'tls-failed') } $errText = { param($r, $fallback) if ($r['endpoint']['err']) { [string]$r['endpoint']['err']['message'] } else { $fallback } } $cloud = @(& $rowsOf 'do-cloud') $geo = @(@($cloud | Where-Object { $_['endpoint']['basis'] -eq 'documented' }) + @($cloud)) | Select-Object -First 1 $cloudTarget = $(if ($geo) { [string]$geo['target'] } else { 'geo.prod.do.dsp.mp.microsoft.com' }) if (-not $geo -or $geo['endpoint']['dns'] -eq 'not-collected') { $checks.Add((New-DDCheck -CheckId 'do-cloud-dns' -Target $cloudTarget -Protocol dns -Result 'not-collected' -Detail $noEp)) } elseif ($geo['endpoint']['dns'] -eq 'fail') { $checks.Add((New-DDCheck -CheckId 'do-cloud-dns' -Target $cloudTarget -Protocol dns -Result 'fail' -Detail (& $errText $geo 'DNS resolution failed'))) } else { $checks.Add((New-DDCheck -CheckId 'do-cloud-dns' -Target $cloudTarget -Protocol dns -Result 'pass' -Detail "Resolved ($((@($geo['endpoint']['dnsTypes']) -join ', ')))")) } if (-not $geo -or $geo['endpoint']['tcp'] -eq 'not-collected') { $checks.Add((New-DDCheck -CheckId 'do-cloud-tls' -Target $cloudTarget -Protocol https -Port 443 -Result 'not-collected' -Detail $(if ($geo -and $geo['endpoint']['dns'] -eq 'fail') { 'Not attempted: the name does not resolve' } else { $noEp }))) } elseif ($geo['endpoint']['tcp'] -eq 'fail') { $checks.Add((New-DDCheck -CheckId 'do-cloud-tls' -Target $cloudTarget -Protocol https -Port 443 -Result 'fail' -LatencyMs $geo['latencyMs'] -Detail ((& $errText $geo 'TCP 443 connect failed') + ' (DO-NET-001)'))) } elseif (& $handshake $geo) { $checks.Add((New-DDCheck -CheckId 'do-cloud-tls' -Target $cloudTarget -Protocol https -Port 443 -Result 'pass' -LatencyMs $geo['latencyMs'] -Detail 'TLS handshake succeeded')) } else { $checks.Add((New-DDCheck -CheckId 'do-cloud-tls' -Target $cloudTarget -Protocol https -Port 443 -Result 'fail' -LatencyMs $geo['latencyMs'] -Detail ((& $errText $geo 'TLS handshake failed') + ' (DO-NET-001)'))) } $inspect = { param($id, $r) $h = [string]$r['target'] $c = $r['endpoint']['cert'] if ($null -eq $c) { return New-DDCheck -CheckId $id -Target $h -Protocol https -Port 443 -Result 'not-collected' -Detail 'TLS handshake did not complete, issuer not observed' } if ($c['issuer'] -eq 'microsoft') { return New-DDCheck -CheckId $id -Target $h -Protocol https -Port 443 -Result 'pass' -LatencyMs $r['latencyMs'] -Detail 'Certificate issued by a Microsoft CA' } $who = $(if ($c['issuerCn']) { "'$($c['issuerCn'])'" } else { 'a certificate authority that is not Microsoft' }) New-DDCheck -CheckId $id -Target $h -Protocol https -Port 443 -Result 'fail' -LatencyMs $r['latencyMs'] -Detail "Certificate issued by $who, not Microsoft: TLS inspection on a pinned DO endpoint (DO-NET-006); there is no workaround" -Evidence ("issuer=$($c['issuer'])" + $(if ($c['issuerCn']) { "; issuerCn=$($c['issuerCn'])" })) } if ($geo) { $checks.Add((& $inspect 'do-cloud-inspect' $geo)) } else { $checks.Add((New-DDCheck -CheckId 'do-cloud-inspect' -Target $cloudTarget -Protocol https -Port 443 -Result 'not-collected' -Detail $noEp)) } $pinnedRows = @(@($cloud) + @(& $rowsOf 'do-array') | Where-Object { $_['endpoint']['tcp'] -eq 'pass' }) if ($pinnedRows.Count -eq 0) { $checks.Add((New-DDCheck -CheckId 'proxy-tls-inspection' -Target $cloudTarget -Protocol https -Port 443 -Result 'not-collected' -Detail $(if ($geo) { 'No pinned DO endpoint completed a connection, so no certificate was observed' } else { $noEp }))) } foreach ($r in $pinnedRows) { $checks.Add((& $inspect 'proxy-tls-inspection' $r)) } $tcpCheck = { param($checkId, $r, $suffix) $port = $r['port'] $x = $r['endpoint']['tcp'] $res = $(if ($x -eq 'pass') { 'pass' } elseif ($x -eq 'fail') { 'fail' } else { 'not-collected' }) # Through a proxy, an HTTP port's connection reaches only the proxy (an HTTPS one is a CONNECT the proxy completed). $via = $r['endpoint']['proxy'] -and $r['endpoint']['proxy']['used'] $ok = $(if (-not $via) { "TCP $port connected" } elseif ($r['protocol'] -eq 'https') { "TCP $port connected through the proxy" } else { "TCP $port`: the proxy is reachable; the HTTP check shows whether it reaches the host" }) $d = $(if ($res -eq 'pass') { $ok } elseif ($res -eq 'fail') { & $errText $r "TCP $port connect failed" } elseif ($r['endpoint']['dns'] -eq 'fail') { 'Not attempted: the name does not resolve' } else { $noEp }) New-DDCheck -CheckId $checkId -Target ([string]$r['target']) -Protocol tcp -Port $port -Result $res -LatencyMs $r['latencyMs'] -Detail "$d$suffix" } $content = @(& $rowsOf 'do-content') if ($content.Count -eq 0) { $checks.Add((New-DDCheck -CheckId 'do-content-cdn' -Target '' -Protocol tcp -Port 80 -Result 'not-collected' -Detail $noEp)) } foreach ($r in $content) { $checks.Add((& $tcpCheck 'do-content-cdn' $r '')) } $wu = @(& $rowsOf 'wu-content') if ($wu.Count -eq 0) { $checks.Add((New-DDCheck -CheckId 'do-metadata-wu' -Target '' -Protocol tcp -Port 80 -Result 'not-collected' -Detail $noEp)) } foreach ($r in $wu) { $checks.Add((& $tcpCheck 'do-metadata-wu' $r '')) } $ranged = @($ep | Where-Object { $_['endpoint']['range'] -in 'pass', 'fail', 'warn' -and $_['endpoint']['id'] -ne 'mcc' }) if ($ranged.Count -eq 0) { $why = $(if ($SkipProbes) { $skipDetail } elseif (@($ObservedStatus).Count -eq 0) { 'No file in the DO job list to test: byte ranges are only requested for a file this device downloaded' } else { 'No file this device downloaded could be tested (none still served, or none on a Microsoft content host)' }) $checks.Add((New-DDCheck -CheckId 'do-range-support' -Target '' -Protocol http -Result 'not-collected' -Detail $why)) } foreach ($r in $ranged) { $x = $r['endpoint']['range'] $code = $(if ($r['endpoint']['err']) { [string]$r['endpoint']['err']['code'] } else { $null }) $d = $(if ($x -eq 'pass') { 'Range request on a file this device downloaded returned 206 Partial Content, with a Content-Range for exactly the bytes asked for' } elseif ($x -eq 'fail' -and $code -eq 'range-mismatch') { (& $errText $r 'The 206 answer did not describe the range asked for') + ' (DO-NET-004)' } elseif ($x -eq 'fail') { 'No 206: the Range request was answered with the whole object; byte ranges are stripped or ignored in the path (DO-NET-004)' } else { & $errText $r 'No answer to the byte-range request' }) $ev = $(if ($r.Contains('evidence')) { [string]$r['evidence'] } else { $null }) $c = New-DDCheck -CheckId 'do-range-support' -Target ([string]$r['target']) -Protocol http -Port $r['port'] -Result $x -LatencyMs $r['latencyMs'] -Detail $d -Evidence $ev if ($code -in 'range-ignored', 'range-mismatch') { $c['code'] = $code } $checks.Add($c) } $byCaller = @($ep | Where-Object { $_['endpoint']['id'] -in 'office-cdn', 'office-cdn-net', 'm365-static', 'intune-win32', 'xbox' -and $_['endpoint']['basis'] -ne 'not-applicable' }) if ($byCaller.Count -eq 0) { $checks.Add((New-DDCheck -CheckId 'do-content-by-caller' -Protocol tcp -Result 'not-applicable' -Detail 'no content types beyond Windows Update observed in the current DO jobs')) } foreach ($r in $byCaller) { $label = [string](@((Get-DDEndpointManifest)['endpoints']) | Where-Object { $_['id'] -eq $r['endpoint']['id'] } | Select-Object -First 1)['label'] if ($r['endpoint']['basis'] -eq 'not-testable') { $checks.Add((New-DDCheck -CheckId 'do-content-by-caller' -Protocol tcp -Port $r['port'] -Result 'not-collected' -Detail "Not testable: wildcard, no observed host (content type: $label)")); continue } $checks.Add((& $tcpCheck 'do-content-by-caller' $r " (content type: $label)")) } # ---- peer to peer -------------------------------------------------------------------------------------------- # Who listens on 7680: DoSvc's own TCP table when it was read (the collector reads it for the endpoint rows), else any # process's listener (the earlier source, which cannot tell DoSvc from another program on the port). $listening = $false $byDoSvc = $null -ne $DoSvcListening if ($byDoSvc) { $listening = [bool]$DoSvcListening } else { $ports = @(Invoke-DDStep -Context $Context -Field 'connectivity.p2p-listener' -Script { Get-DDListeningTcpPorts } -Default @()) $listening = $ports -contains 7680 } $listenKnown = $byDoSvc -or -not $Context.NotCollected.Exists([Predicate[object]] { param($e) $e['field'] -eq 'connectivity.p2p-listener' }) $stopped = $null -ne $DoSvc -and [string](Get-DDProp $DoSvc 'Status') -and [string](Get-DDProp $DoSvc 'Status') -ne 'Running' $who = $(if ($byDoSvc) { 'DoSvc' } else { 'A process' }) # A transfer is active while a job is Downloading or DoSvc holds a peer connection (TCP 7680 at either end). A Caching # job has finished ("download completed successfully and is ready to upload or uploading", Microsoft) and stays so for # days, so it does not by itself mean DoSvc must be listening. $statusIs = { param($rx) [bool](@($ObservedStatus) | Where-Object { [string](Get-DDProp $_ 'status') -match $rx } | Select-Object -First 1) } $downloading = & $statusIs '^(?i)downloading$' $cached = & $statusIs '^(?i)caching$' $activeTransfer = $downloading -or ($DoSvcPeerConnection -eq $true) # The reasons Microsoft's FAQ gives for not listening that this device's own facts show (battery, VPN, cellular). $batteryRead = -not $Context.NotCollected.Exists([Predicate[object]] { param($e) $e['field'] -eq 'vitals.battery' }) $quiet = New-Object 'System.Collections.Generic.List[string]' if ($batteryRead -and $null -ne $Vitals -and $Vitals['onBattery']) { $quiet.Add('it is on battery') } if ($null -ne $Network -and $Network['isVpnActive'] -and (Get-DDEffectiveValue -Effective $DoEffective -Name 'DOAllowVPNPeerCaching') -ne 1) { $quiet.Add('a VPN is connected and DOAllowVPNPeerCaching does not allow peering over it') } if ($null -ne $Network -and $Network['adapterType'] -eq 'Cellular') { $quiet.Add('it is on a cellular network') } $quietText = ($quiet -join '; ') $transferText = $(if ($downloading) { 'a job is downloading' } else { 'DoSvc holds a peer connection' }) if ($peeringOff) { $checks.Add((New-DDCheck -CheckId 'p2p-listener' -Target 'localhost' -Protocol tcp -Port 7680 -Result 'not-applicable' -Detail "Peering is disabled by $modeText")) } elseif ($listening) { $checks.Add((New-DDCheck -CheckId 'p2p-listener' -Target 'localhost' -Protocol tcp -Port 7680 -Result 'pass' -Detail "$who is listening on TCP 7680")) } elseif ($activeTransfer -and $quiet.Count -gt 0) { $checks.Add((New-DDCheck -CheckId 'p2p-listener' -Target 'localhost' -Protocol tcp -Port 7680 -Result 'warn' -Detail "Not listening although $transferText. Microsoft documents that DoSvc does not listen while $quietText")) } elseif ($activeTransfer) { $checks.Add((New-DDCheck -CheckId 'p2p-listener' -Target 'localhost' -Protocol tcp -Port 7680 -Result 'fail' -Detail "Not listening although $transferText, and none of the reasons Microsoft gives for that (battery, VPN, cellular network) applies to this device")) } elseif ($stopped) { $checks.Add((New-DDCheck -CheckId 'p2p-listener' -Target 'localhost' -Protocol tcp -Port 7680 -Result 'warn' -Detail 'DoSvc is not running (it stops when idle), so nothing listens on TCP 7680')) } elseif ($cached) { $checks.Add((New-DDCheck -CheckId 'p2p-listener' -Target 'localhost' -Protocol tcp -Port 7680 -Result 'warn' -Detail "Not listening. Its jobs are finished and cached ('Caching'), and no download or peer connection is active: DoSvc stops listening when idle$(if ($quiet.Count -gt 0) { ", and while $quietText" })")) } else { $checks.Add((New-DDCheck -CheckId 'p2p-listener' -Target 'localhost' -Protocol tcp -Port 7680 -Result 'warn' -Detail 'Not listening. Expected when idle: DoSvc stops listening with no transfers, no cached files, low battery, Connected Standby, cellular or VPN restrictions')) } if ($peeringOff) { $checks.Add((New-DDCheck -CheckId 'p2p-outbound' -Protocol tcp -Port 7680 -Result 'not-applicable' -Detail "Peering is disabled by $modeText")) } elseif ($peers.Count -eq 0) { $checks.Add((New-DDCheck -CheckId 'p2p-outbound' -Protocol tcp -Port 7680 -Result 'not-applicable' -Detail 'No peers known to this device right now')) } else { for ($i = 0; $i -lt $peers.Count; $i++) { $r = & $probe "peer:$i"; $x = & $fromProbe $r 'TCP 7680 connected' 'TCP 7680 connect' $tgt = 'peer:' + (Get-DDShortHash -Value $peers[$i] -Salt $Context.TenantSalt -Domain 'peer') $checks.Add((New-DDCheck -CheckId 'p2p-outbound' -Target $tgt -Protocol tcp -Port 7680 -Result $x.Result -LatencyMs $x.Ms -Detail $x.Detail)) } } # firewall rules: inbound Allow rules for TCP 7680 found by port as well as by name (Test-DDTcp7680Allow); a rule that # is enabled but whose PrimaryStatus is not OK is not in force. $rules = @($FirewallRules) $tcpAllow = @($rules | Where-Object { (Test-DDTcp7680Allow -Rule $_) -and $_['enabled'] }) $inForce = @($tcpAllow | Where-Object { $null -eq (Get-DDProp $_ 'primaryStatus') -or (Get-DDProp $_ 'primaryStatus') -eq 'OK' }) $notInForce = @($tcpAllow | Where-Object { $null -ne (Get-DDProp $_ 'primaryStatus') -and (Get-DDProp $_ 'primaryStatus') -ne 'OK' }) $blockers = @($rules | Where-Object { $_['direction'] -eq 'Inbound' -and $_['action'] -eq 'Block' -and $_['enabled'] -and $_['displayName'] -notmatch '(?i)Delivery Optimization' -and ([string]$_['localPort'] -match '(?i)(^|,)\s*Any\s*(,|$)' -or [string]$_['localPort'] -eq '' -or (Test-DDPortSpec -Spec ([string]$_['localPort']) -Port 7680)) -and [string]$_['protocol'] -match '^(?i)(TCP|6|Any|)$' }) # A profile whose policy refuses every inbound connection ("block all incoming", AllowInboundRules=False) overrides # any allow rule. Only enabled profiles count: a disabled firewall blocks nothing. $shields = @(@($FirewallProfiles) | Where-Object { $null -ne $_ -and [string](Get-DDProp $_ 'AllowInboundRules') -eq 'False' -and [string](Get-DDProp $_ 'Enabled') -ne 'False' } | ForEach-Object { [string](Get-DDProp $_ 'Name') }) $fwCheck = $null $profiles = @(@($FirewallProfiles) | Where-Object { $null -ne $_ }) $allOff = $profiles.Count -gt 0 -and @($profiles | Where-Object { [string](Get-DDProp $_ 'Enabled') -ne 'False' }).Count -eq 0 if ($peeringOff) { $fwCheck = New-DDCheck -CheckId 'p2p-firewall-rule' -Protocol local -Port 7680 -Result 'not-applicable' -Detail "Peering is disabled by $modeText" } elseif ($allOff) { $fwCheck = New-DDCheck -CheckId 'p2p-firewall-rule' -Protocol local -Port 7680 -Result 'pass' -Detail 'Windows Defender Firewall is off on every profile, so it blocks nothing (another firewall may be in its place)'; $fwCheck['code'] = 'firewall-off' } elseif ($shields.Count -gt 0) { $fwCheck = New-DDCheck -CheckId 'p2p-firewall-rule' -Protocol local -Port 7680 -Result 'fail' -Detail "Firewall policy blocks all inbound connections on the $($shields -join ', ') profile (AllowInboundRules=False), whatever the Delivery Optimization rules say (DO-NET-010)"; $fwCheck['code'] = 'inbound-shield' } elseif ($blockers.Count -gt 0) { $fwCheck = New-DDCheck -CheckId 'p2p-firewall-rule' -Protocol local -Port 7680 -Result 'fail' -Detail "An enabled inbound Block rule could shadow the DO allow rules: '$($blockers[0]['displayName'])' (DO-NET-010)"; $fwCheck['code'] = 'shadowed' } elseif ($inForce.Count -eq 0 -and $notInForce.Count -gt 0) { $fwCheck = New-DDCheck -CheckId 'p2p-firewall-rule' -Protocol local -Port 7680 -Result 'fail' -Detail "The inbound Allow rule for TCP 7680 '$($notInForce[0]['displayName'])' is enabled but its status is $((Get-DDProp $notInForce[0] 'primaryStatus')), so it is not in force$(Get-DDEnforcementText -Rule $notInForce[0]) (DO-NET-019)" $fwCheck['code'] = 'not-in-force' } elseif ($inForce.Count -eq 0) { $fwCheck = New-DDCheck -CheckId 'p2p-firewall-rule' -Protocol local -Port 7680 -Result 'fail' -Detail 'No enabled inbound Allow rule for TCP 7680 found, by name or by port (absent or disabled, DO-NET-010)'; $fwCheck['code'] = 'no-allow' } else { $named = @($inForce | ForEach-Object { "'$($_['displayName'])'" } | Select-Object -First 2) -join ', ' $fwCheck = New-DDCheck -CheckId 'p2p-firewall-rule' -Protocol local -Port 7680 -Result 'pass' -Detail "$($inForce.Count) enabled inbound Allow rule(s) admit TCP 7680 ($named), no shadowing Block rule" } $checks.Add($fwCheck) # Inbound from peers, as far as the device can tell: its firewall admits TCP 7680 and DoSvc listens on it. Whether the # network between devices lets peers through is not measured here. $inb = { param($r, $d, $code) $c = New-DDCheck -CheckId 'p2p-inbound' -Target 'localhost' -Protocol tcp -Port 7680 -Result $r -Detail $d; if ($code) { $c['code'] = $code }; $checks.Add($c) } if ($peeringOff) { & $inb 'not-applicable' "Peering is disabled by $modeText" $null } elseif ($fwCheck['result'] -eq 'fail') { & $inb 'fail' "Peers cannot connect in: the firewall refuses TCP 7680 on this device (see the firewall rule check)" 'firewall' } elseif (-not $listenKnown) { & $inb 'not-collected' 'Inbound reachability needs reflection by a peer or the fleet collector; it cannot be measured from the device alone' $null } elseif ($listening -and $fwCheck['result'] -eq 'pass') { & $inb 'pass' "$who is listening on TCP 7680 and the firewall admits it: ready to serve peers. Whether the network between devices lets them in is not measured from the device" 'listening' } elseif ($listening) { & $inb 'not-collected' "$who is listening on TCP 7680, but the firewall rules could not be judged" $null } elseif ($stopped) { & $inb 'warn' "DoSvc is not running, so it is not listening on TCP 7680 and cannot serve peers now. $($script:DDListenerReasons)" 'not-listening' } else { & $inb 'warn' "Not listening on TCP 7680 now, so it cannot serve peers at this moment. $($script:DDListenerReasons)" 'not-listening' } $lpm = $null; if ($Network) { $lpm = $Network['localPolicyMergeEnabled'] } if ($null -eq $lpm) { $checks.Add((New-DDCheck -CheckId 'local-policy-merge' -Protocol local -Result 'not-collected' -Detail 'LocalPolicyMerge state could not be read')) } elseif ($lpm) { $checks.Add((New-DDCheck -CheckId 'local-policy-merge' -Protocol local -Result 'pass' -Detail 'Local firewall rules are merged')) } else { $checks.Add((New-DDCheck -CheckId 'local-policy-merge' -Protocol local -Result 'warn' -Detail 'LocalPolicyMerge is disabled: this can stop the DHCP client retrieving option 235, breaking Connected Cache discovery (DO-NET-011)')) } # Teredo: the operational State (qualified, dormant, probe, offline; none: not active), not only the configured Type. # Microsoft: "If the state is 'offline', Teredo is not active, which prevents the user from connecting to other users # via their Teredo addresses"; Windows also keeps it offline on a network with a domain controller. if ($modeNum -in 2, 3) { $t = Get-DDTeredoFacts -Context $Context $qualified = $null -ne $t -and $t.State -eq 'qualified' $r = & $rowsOf 'teredo-do' | Select-Object -First 1 # UDP 3544 cannot be confirmed by a connectionless probe; a qualified Teredo client has reached its server over it. if ($null -eq $r -or $r['endpoint']['dns'] -eq 'not-collected') { $checks.Add((New-DDCheck -CheckId 'teredo-endpoint' -Target $script:DDTeredoHost -Protocol udp -Port 3544 -Result 'not-collected' -Detail $noEp)) } elseif ($r['endpoint']['dns'] -eq 'pass' -and $qualified -and $t.ServerName -eq $script:DDTeredoHost) { $checks.Add((New-DDCheck -CheckId 'teredo-endpoint' -Target $script:DDTeredoHost -Protocol udp -Port 3544 -Result 'pass' -Detail 'Name resolves, and Teredo is qualified with this server, so UDP 3544 to it works')) } elseif ($r['endpoint']['dns'] -eq 'pass') { $checks.Add((New-DDCheck -CheckId 'teredo-endpoint' -Target $script:DDTeredoHost -Protocol udp -Port 3544 -Result 'not-collected' -Detail "Name resolves; whether UDP 3544 reaches it cannot be measured by a connectionless probe$(if ($qualified) { ' (Teredo is qualified with another server)' } else { ', and Teredo is not qualified on this device to show it' })")) } else { $checks.Add((New-DDCheck -CheckId 'teredo-endpoint' -Target $script:DDTeredoHost -Protocol udp -Port 3544 -Result 'fail' -Detail "$(& $errText $r 'Name does not resolve') (DO-NET-003)")) } $tc = { param($result, $code, $detail) $c = New-DDCheck -CheckId 'teredo-state' -Protocol local -Result $result -Detail $detail -Evidence $(if ($t) { "type=$($t.Type); state=$(if ($t.State) { $t.State } else { '(not reported)' }); error=$(if ($t.Error) { $t.Error } else { 'none' }); source=$($t.Source)" }); if ($code) { $c['code'] = $code }; $checks.Add($c) } $typeText = $(if ($t -and $t.Type) { "type $($t.Type)" } else { 'type not reported' }) $errNote = $(if ($t -and $t.Error) { "; error: $($t.Error)" } else { '' }) if ($null -eq $t) { & $tc 'not-collected' $null 'The Teredo state could not be read' } elseif ($t.Type -eq 'disabled') { & $tc 'fail' 'disabled' 'Teredo is disabled; Group (2) and Internet (3) modes need it to reach peers behind other NATs (DO-NET-003)' } elseif ($t.State -eq 'qualified') { & $tc 'pass' 'qualified' "Teredo is qualified ($typeText): it has reached its server over UDP 3544" } elseif ($t.State -eq 'dormant') { & $tc 'pass' 'dormant' "Teredo is dormant ($typeText): configured and idle, it qualifies again when it is needed" } elseif (-not $t.State) { & $tc 'not-collected' $null "Teredo is configured ($typeText); its state was not reported, so whether it is active is not known" } elseif ($t.State -eq 'inactive') { & $tc 'warn' 'inactive' "Teredo is not active ($typeText)$errNote, so peers behind other NATs cannot be reached through it. Windows keeps Teredo offline on a network with a domain controller" } else { & $tc 'warn' $(if ($t.State -eq 'offline') { 'offline' } else { 'not-qualified' }) "Teredo is $($t.State) ($typeText)$errNote, so peers behind other NATs cannot be reached through it. Windows keeps Teredo offline on a network with a domain controller" } } else { $why = "Teredo is only needed for Group or Internet mode ($modeText)" $checks.Add((New-DDCheck -CheckId 'teredo-endpoint' -Target $script:DDTeredoHost -Protocol udp -Port 3544 -Result 'not-applicable' -Detail $why)) $checks.Add((New-DDCheck -CheckId 'teredo-state' -Protocol local -Result 'not-applicable' -Detail $why)) } # ---- Connected Cache ----------------------------------------------------------------------------------------- # The cache server is infrastructure and is reported in clear, under -Anonymise as well (ADR-0031). $tag = { param($h) $h } if ($cacheHosts.Count -eq 0) { $checks.Add((New-DDCheck -CheckId 'mcc-config' -Protocol local -Result 'not-applicable' -Detail 'No DOCacheHost configured')) $checks.Add((New-DDCheck -CheckId 'mcc-reachable' -Protocol tcp -Port 80 -Result 'not-applicable' -Detail 'No DOCacheHost configured')) $checks.Add((New-DDCheck -CheckId 'mcc-serving' -Protocol http -Result 'not-applicable' -Detail 'No DOCacheHost configured')) } else { $checks.Add((New-DDCheck -CheckId 'mcc-config' -Target (& $tag $cacheHosts[0]) -Protocol local -Result 'pass' -Detail "$($cacheHosts.Count) cache host(s) configured")) $reach = $false; $verified = $false $mccRows = @(& $rowsOf 'mcc') foreach ($h in $cacheHosts) { $bare = ConvertTo-DDCacheHostName $h $r = $mccRows | Where-Object { $_['target'] -eq $bare } | Select-Object -First 1 if ($null -eq $r) { $checks.Add((New-DDCheck -CheckId 'mcc-reachable' -Target (& $tag $h) -Protocol tcp -Port 80 -Result 'not-collected' -Detail $noEp)); continue } $x = & $tcpCheck 'mcc-reachable' $r '' if ($x['result'] -eq 'pass') { $reach = $true } if ($r['endpoint']['http'] -eq 'pass') { $verified = $true } $checks.Add($x) } $cacheBytes = [System.Numerics.BigInteger]::Zero foreach ($s in @($ObservedStatus)) { $cacheBytes += (ConvertTo-DDBigInt (Get-DDProp $s 'bytesFromCacheServer')) } if (@($ObservedStatus).Count -eq 0 -and $verified) { $checks.Add((New-DDCheck -CheckId 'mcc-serving' -Target (& $tag $cacheHosts[0]) -Protocol http -Result 'pass' -Detail "Answers Microsoft's verification request; no DO jobs to inspect right now")) } elseif (@($ObservedStatus).Count -eq 0) { $checks.Add((New-DDCheck -CheckId 'mcc-serving' -Target (& $tag $cacheHosts[0]) -Protocol http -Result 'not-collected' -Detail 'No DO jobs to inspect right now')) } elseif ($cacheBytes -gt 0) { $checks.Add((New-DDCheck -CheckId 'mcc-serving' -Target (& $tag $cacheHosts[0]) -Protocol http -Result 'pass' -Detail 'BytesFromCacheServer is above zero in current jobs')) } elseif ($reach) { $checks.Add((New-DDCheck -CheckId 'mcc-serving' -Target (& $tag $cacheHosts[0]) -Protocol http -Result 'warn' -Detail 'Cache host is reachable but delivered nothing in current jobs')) } else { $checks.Add((New-DDCheck -CheckId 'mcc-serving' -Target (& $tag $cacheHosts[0]) -Protocol http -Result 'fail' -Detail 'Cache host unreachable and nothing delivered (DO-NET-007)')) } } # ---- proxy --------------------------------------------------------------------------------------------------- $checks.Add((New-DDCheck -CheckId 'proxy-source-ip' -Protocol local -Result 'not-applicable' -Detail 'Evaluated by the service by comparing network.publicEgressHash across devices on one subnet (DO-NET-009)')) if ($null -eq $Network) { $checks.Add((New-DDCheck -CheckId 'proxy-scope' -Protocol local -Result 'not-collected' -Detail 'proxy state could not be read')) } else { $px = $Network['proxy'] if (-not $px['configured']) { $checks.Add((New-DDCheck -CheckId 'proxy-scope' -Protocol local -Result 'pass' -Detail 'No proxy configured')) } elseif ($px['wininetScope'] -eq 'per-user') { $checks.Add((New-DDCheck -CheckId 'proxy-scope' -Protocol local -Result 'fail' -Detail 'Per-user WinINET proxy only: background downloads (NetworkService) will not use it (DO-NET-012)')) } elseif ($px['wininetScope'] -eq 'none' -and $px['netshConfigured']) { $checks.Add((New-DDCheck -CheckId 'proxy-scope' -Protocol local -Result 'warn' -Detail 'netsh winhttp proxy only: not used by interactive downloads, no PAC or authentication; device-wide WinINET is recommended (DO-NET-012)')) } else { $checks.Add((New-DDCheck -CheckId 'proxy-scope' -Protocol local -Result 'pass' -Detail "Device-wide proxy ($($px['wininetScope']))")) } } $checks.Add((Get-DDPacCheck -Network $Network -Checks $checks.ToArray())) # ---- local prerequisites ------------------------------------------------------------------------------------- if ($null -eq $DoSvc) { $checks.Add((New-DDCheck -CheckId 'dosvc-running' -Target 'DoSvc' -Protocol local -Result 'not-collected' -Detail 'DoSvc state could not be read')) } elseif ($DoSvc.StartType -eq 'Disabled') { $c = New-DDCheck -CheckId 'dosvc-running' -Target 'DoSvc' -Protocol local -Result 'fail' -Detail 'DoSvc start type is Disabled; no amount of policy will make DO work' $c['code'] = 'start-disabled' $checks.Add($c) } elseif ($DoSvc.Status -eq 'Running') { $checks.Add((New-DDCheck -CheckId 'dosvc-running' -Target 'DoSvc' -Protocol local -Result 'pass' -Detail "DoSvc running ($($DoSvc.StartType))")) } else { $checks.Add((New-DDCheck -CheckId 'dosvc-running' -Target 'DoSvc' -Protocol local -Result 'warn' -Detail "DoSvc is $($DoSvc.Status) ($($DoSvc.StartType)); it is trigger-started and stops when idle")) } if ($null -eq $modeNum) { $checks.Add((New-DDCheck -CheckId 'download-mode' -Protocol local -Result 'not-collected' -Detail 'Effective DODownloadMode not available')) } elseif ($peeringOff) { $checks.Add((New-DDCheck -CheckId 'download-mode' -Protocol local -Result 'warn' -Detail "DODownloadMode=$modeNum disables peering; peer-to-peer checks are not-applicable")) } else { $checks.Add((New-DDCheck -CheckId 'download-mode' -Protocol local -Result 'pass' -Detail "DODownloadMode=$modeNum")) } # Defaults when the setting is not configured: 4 GB RAM and 32 GB disk, from the Delivery Optimization reference # (https://learn.microsoft.com/windows/deployment/do/waas-delivery-optimization-reference). The Policy CSP page # (https://learn.microsoft.com/windows/client-management/mdm/policy-csp-deliveryoptimization) lists 0 for the same settings, so # the defaults are a documented assumption; a setting that is configured is always used as set. $minRamSet = Get-DDEffectiveValue -Effective $DoEffective -Name 'DOMinRAMAllowedToPeer'; $minRam = $minRamSet; if ($null -eq $minRam) { $minRam = 4 } $minDiskSet = Get-DDEffectiveValue -Effective $DoEffective -Name 'DOMinDiskSizeAllowedToPeer'; $minDisk = $minDiskSet; if ($null -eq $minDisk) { $minDisk = 32 } $driveText = $(if ($DiskDrive) { "cache drive $DiskDrive" } else { 'cache drive' }) if ($peeringOff) { $checks.Add((New-DDCheck -CheckId 'resource-eligibility' -Protocol local -Result 'not-applicable' -Detail "Peering is disabled by $modeText")) } elseif ($null -eq $RamGB -or $null -eq $DiskGB) { $checks.Add((New-DDCheck -CheckId 'resource-eligibility' -Protocol local -Result 'not-collected' -Detail 'RAM or disk size unavailable')) } else { $why = New-Object 'System.Collections.Generic.List[string]' $ramFail = [double]$RamGB -lt [double]$minRam $diskFail = [double]$DiskGB -lt [double]$minDisk if ($ramFail) { $why.Add("RAM $RamGB GB < DOMinRAMAllowedToPeer $minRam GB") } if ($diskFail) { $why.Add("$driveText $DiskGB GB < DOMinDiskSizeAllowedToPeer $minDisk GB") } $assumed = @(@($(if ($null -eq $minRamSet) { 'DOMinRAMAllowedToPeer' }), $(if ($null -eq $minDiskSet) { 'DOMinDiskSizeAllowedToPeer' })) | Where-Object { $_ }) $assumedText = $(if ($assumed.Count -gt 0) { " ($($assumed -join ' and ') not set: documented default assumed)" } else { '' }) if ($why.Count -gt 0) { $c = New-DDCheck -CheckId 'resource-eligibility' -Protocol local -Result 'fail' -Detail ('Excluded from peering by its own policy: ' + ($why -join '; ') + $assumedText) $c['code'] = $(if ($ramFail -and $diskFail) { 'ram-disk' } elseif ($ramFail) { 'ram' } else { 'disk' }) $checks.Add($c) } else { $checks.Add((New-DDCheck -CheckId 'resource-eligibility' -Protocol local -Result 'pass' -Detail "RAM $RamGB GB and $driveText $DiskGB GB meet the minimums ($minRam GB / $minDisk GB)")) } } if ($peeringOff) { $checks.Add((New-DDCheck -CheckId 'adapter-eligibility' -Protocol local -Result 'not-applicable' -Detail "Peering is disabled by $modeText")) } elseif ($null -eq $Network) { $checks.Add((New-DDCheck -CheckId 'adapter-eligibility' -Protocol local -Result 'not-collected' -Detail 'adapter facts unavailable')) } else { $vpnAllowed = Get-DDEffectiveValue -Effective $DoEffective -Name 'DOAllowVPNPeerCaching' $why = New-Object 'System.Collections.Generic.List[string]' if ($Network['isVpnActive'] -and -not ($vpnAllowed -eq 1)) { $why.Add('VPN active and DOAllowVPNPeerCaching is not enabled') } if ($Network['isMetered']) { $why.Add('metered connection') } if ($Network['adapterType'] -eq 'Cellular') { $why.Add('cellular adapter') } if ($why.Count -gt 0) { $checks.Add((New-DDCheck -CheckId 'adapter-eligibility' -Protocol local -Result 'warn' -Detail ('Peering restricted: ' + ($why -join '; ')))) } else { $checks.Add((New-DDCheck -CheckId 'adapter-eligibility' -Protocol local -Result 'pass' -Detail "Adapter type $($Network['adapterType'])")) } } # ---- Delivery Optimization health (Health.ps1): geo service, cache server in use, DHCP 235/234, rule scope, outbound # blocks, cache folder, state file, uploading on battery --------------------------------------------------------------- foreach ($c in (New-DDHealthChecks -DoEffective $DoEffective -Network $Network -FirewallRules $rules -Geo $GeoAnswer -CacheHost $CacheResolution -Dhcp $Dhcp ` -CacheFolder $CacheFolder -Vitals $Vitals -PowerPlan $PowerPlan -ModeNum $modeNum -SkipProbes:$SkipProbes -BatteryCollected $batteryRead)) { $checks.Add($c) } if ($SkipProbes) { Add-DDNotCollected -Context $Context -Field 'connectivity' -Reason 'skipped-by-flag' -Detail 'network probes skipped by -SkipConnectivityChecks' } $kept = Select-DDMatrixRows -Checks $checks.ToArray() -Max $script:DDMaxMatrixChecks if ($kept.Dropped -gt 0) { Add-DDNotCollected -Context $Context -Field 'connectivity' -Reason 'error' -Detail "$($kept.Dropped) per-host connectivity row(s) not sent: the matrix is capped at $($script:DDMaxMatrixChecks) checks plus $($script:DDMaxEndpointRows) endpoint-health rows" } , @(@($kept.Rows) + @($ep | Select-Object -First $script:DDMaxEndpointRows)) } |