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 # The user's proxy settings, as Internet Options writes them. $script:NetworkUserProxyKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings' 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 } ) # The user's proxy (WinINET), read from the hive of whoever this process runs as. That # is the Technician's session only in the Main Part and unelevated; Elevated below is # what lets the Judge say so rather than present another account's settings as the # user's. $null when the key could not be read, which is not the same as no proxy. $userProxy = $null try { $settings = Get-ItemProperty -Path $script:NetworkUserProxyKey -ErrorAction Stop $userProxy = [pscustomobject]@{ ProxyEnable = $settings.ProxyEnable ProxyServer = $settings.ProxyServer ProxyOverride = $settings.ProxyOverride AutoConfigURL = $settings.AutoConfigURL } } catch { } # The system proxy (WinHTTP), which is what Office uses for its background requests. # Kept as netsh's own text and parsed by the Judge: the output is localised, and a # parse that goes wrong has to be something a fixture can catch. There are two views on # 64-bit Windows, and 32-bit Office reads the SysWOW64 one (KB 2847833). A 32-bit # PowerShell would be redirected to SysWOW64 by "System32", so it asks for Sysnative. $nativeNetsh = Join-Path $env:windir 'System32\netsh.exe' if ([Environment]::Is64BitOperatingSystem -and -not [Environment]::Is64BitProcess) { $nativeNetsh = Join-Path $env:windir 'Sysnative\netsh.exe' } $winHttp64 = '' try { $winHttp64 = (& $nativeNetsh winhttp show proxy 2>$null | Out-String) } catch { } # $null only where there is no 32-bit view at all; a view that would not answer is an # empty string, which the Judge reports as unreadable rather than as absent. $winHttp32 = $null $wowNetsh = Join-Path $env:windir 'SysWOW64\netsh.exe' if (Test-Path -LiteralPath $wowNetsh) { $winHttp32 = '' try { $winHttp32 = (& $wowNetsh winhttp show proxy 2>$null | Out-String) } catch { } } [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 UserProxy = $userProxy WinHttpProxy64Text = $winHttp64 WinHttpProxy32Text = $winHttp32 # 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 New-ProxyFinding -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 } # The proxy readings exactly as they came, so second level can see what the Judge made # of netsh's localised text rather than trust it. $user = Get-DataProperty $Data 'UserProxy' $lines = @('HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings') if ($null -eq $user) { $lines += ' (not readable)' } else { foreach ($name in 'ProxyEnable', 'ProxyServer', 'ProxyOverride', 'AutoConfigURL') { $lines += ' {0} = {1}' -f $name, (Get-DataProperty $user $name) } } foreach ($view in @(@('64-bit', 'WinHttpProxy64Text'), @('32-bit (SysWOW64)', 'WinHttpProxy32Text'))) { $lines += '' $lines += 'netsh winhttp show proxy, {0}:' -f $view[0] $text = Get-DataProperty $Data $view[1] if ($null -eq $text) { $lines += ' (not gathered)' } else { $lines += "$text".Trim("`r", "`n") } } New-Section -Title (Get-Text 'Title.Network.ProxySettings') -Text ($lines -join "`r`n") } function ConvertFrom-WinHttpProxyText { <# .SYNOPSIS Parses what "netsh winhttp show proxy" printed into direct, proxy server and bypass list. Pure, and reaches nothing. .DESCRIPTION netsh prints its labels in the UI language, so this reads by structure and never by label. The settings are the indented lines; the heading and every error message are not. A proxy is two "label : value" lines, server first and bypass list second. Direct is one indented line with no colon, which in German reads "DirectAccess (kein Proxyserver)." - not the "Direkter Zugriff" one might guess - and in English "Direct access (no proxy server).", so "direct" is the one token both share. Anything else is not recognised, because an error read as direct would be a false all-clear. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyString()][string]$Text) $body = @("$Text" -split '\r?\n' | Where-Object { $_ -match '^\s{2,}\S' } | ForEach-Object { $_.Trim() }) # " : " with the spaces, because a proxy value carries colons of its own (host:port). $values = @($body | Where-Object { $_ -match '^[^:]+?\s:\s' } | ForEach-Object { ($_ -split '\s:\s', 2)[1].Trim() }) $server = $null $bypass = $null $direct = $false if ($values.Count -ge 1 -and $values[0]) { $server = $values[0] # "(keine)" / "(none)": the placeholder for an empty list, recognised by its shape. if ($values.Count -ge 2 -and $values[1] -notmatch '^\(.*\)$') { $bypass = $values[1] } } elseif (@($body | Where-Object { $_ -notmatch ':' -and $_ -match '(?i)direct' }).Count) { $direct = $true } [pscustomobject]@{ PSTypeName = 'Gutcheck.WinHttpProxy' Recognised = $direct -or [bool]$server Direct = $direct ProxyServer = $server BypassList = $bypass } } function Get-UserProxyDescription { [CmdletBinding()] param([AllowNull()]$UserProxy) if ($null -eq $UserProxy) { return (Get-Text 'Value.Network.ProxyUnreadable') } $parts = @() $pac = "$(Get-DataProperty $UserProxy 'AutoConfigURL')".Trim() if ($pac) { $parts += (Get-Text 'Value.Network.ProxyScript') -f $pac } $server = Get-UserProxyServer $UserProxy if ($server) { $override = "$(Get-DataProperty $UserProxy 'ProxyOverride')".Trim() if ($override) { $parts += (Get-Text 'Value.Network.ProxyServerWithBypass') -f $server, $override } else { $parts += (Get-Text 'Value.Network.ProxyServer') -f $server } } if (-not $parts.Count) { return (Get-Text 'Value.Network.ProxyDirect') } $parts -join '; ' } function Get-UserProxyServer { # The server only while it is switched on: Internet Options keeps the last one typed in # after the box is unticked, and that one is used by nothing. [CmdletBinding()] param([AllowNull()]$UserProxy) if ((ConvertTo-Number (Get-DataProperty $UserProxy 'ProxyEnable')) -ne 1) { return $null } $server = "$(Get-DataProperty $UserProxy 'ProxyServer')".Trim() if ($server) { return $server } $null } function Get-WinHttpProxyDescription { [CmdletBinding()] param([AllowNull()]$Parsed) if ($null -eq $Parsed) { return (Get-Text 'Value.Network.ProxyViewAbsent') } if (-not $Parsed.Recognised) { return (Get-Text 'Value.Network.ProxyUnreadable') } if ($Parsed.Direct) { return (Get-Text 'Value.Network.ProxyDirect') } if ($Parsed.BypassList) { return (Get-Text 'Value.Network.ProxyServerWithBypass') -f $Parsed.ProxyServer, $Parsed.BypassList } (Get-Text 'Value.Network.ProxyServer') -f $Parsed.ProxyServer } function New-ProxyFinding { <# .SYNOPSIS Judges the user's proxy (WinINET) against the system proxy (WinHTTP) Office uses for its background requests. See docs/research/outlook-connectivity-checks.md, 6. #> [CmdletBinding()] param([AllowNull()]$Data, [hashtable]$Parameters) $user = Get-DataProperty $Data 'UserProxy' # The 32-bit view is $null only on a Windows without SysWOW64; the 64-bit one always # exists, so its absence is data gathered before the proxy was, and reads unreadable. $views = @( [pscustomobject]@{ Check = (Get-Text 'Check.Network.WinHttpProxy64'); Text = (Get-DataProperty $Data 'WinHttpProxy64Text'); Optional = $false } [pscustomobject]@{ Check = (Get-Text 'Check.Network.WinHttpProxy32'); Text = (Get-DataProperty $Data 'WinHttpProxy32Text'); Optional = $true } ) foreach ($view in $views) { $parsed = $null if ($null -ne $view.Text -or -not $view.Optional) { $parsed = ConvertFrom-WinHttpProxyText -Text $view.Text } $view | Add-Member -NotePropertyName Parsed -NotePropertyValue $parsed } # The effective configuration, always: a Technician asked about a proxy needs the # answer even when nothing is wrong with it. $hint = '' if ($null -eq $user) { $hint = (Get-Text 'Hint.Network.UserProxyUnreadable') } elseif (Get-DataProperty $Data 'Elevated') { $hint = (Get-Text 'Hint.Network.ProxyReadInElevatedSession') } New-Finding -Category Network -Check (Get-Text 'Check.Network.Proxy') -Severity INFO ` -Value ((Get-Text 'Value.Network.ProxySummary') -f (Get-UserProxyDescription $user), (Get-WinHttpProxyDescription $views[0].Parsed), (Get-WinHttpProxyDescription $views[1].Parsed)) ` -Hint $hint # KB 2332495: WinHTTP cannot load a PAC file from a file:// URL, so Outlook cannot either. $pac = "$(Get-DataProperty $user 'AutoConfigURL')".Trim() if ($pac -match '^(?i)file:') { New-Finding -Category Network -Check (Get-Text 'Check.Network.ProxyScript') -Severity FAIL ` -Value $pac -Hint (Get-Text 'Hint.Network.ProxyScriptFromFile') } foreach ($view in $views) { if ($null -eq $view.Parsed) { continue } if (-not $view.Parsed.Recognised) { New-UnavailableFinding -Category Network -Check $view.Check -Hint (Get-Text 'Hint.Network.WinHttpProxyUnreadable') continue } # Nothing to compare against when the user's settings could not be read; the # summary above already says so. if ($null -eq $user -or $view.Parsed.Direct) { continue } # KB 2847833: a WinHTTP proxy that is not the user's breaks free/busy, out-of-office # and MailTips. Direct WinHTTP is not warned about, whatever the user has: the # research grades it OK beside a PAC, and it is exactly what that KB's own fix, # "netsh winhttp reset proxy", leaves behind beside a fixed proxy. $userServer = Get-UserProxyServer $user if ($userServer -and $userServer -eq $view.Parsed.ProxyServer.Trim()) { continue } New-Finding -Category Network -Check $view.Check -Severity WARN ` -Value ((Get-Text 'Value.Network.WinHttpDiffers') -f $view.Parsed.ProxyServer, (Get-UserProxyDescription $user)) ` -Hint (Get-Text 'Hint.Network.WinHttpProxyDiffers') } } 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') } } |