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 # Where the active power plan keeps USB selective suspend: the USB settings subgroup and the # setting in it. Verified with "powercfg /q" on Windows 11; the names powercfg prints beside # them are localised, the GUIDs are not. $script:NetworkUsbPowerSubgroup = '2a737441-1930-4402-8d77-b2bebba308a3' $script:NetworkUsbSuspendSetting = '48e6b7a6-50f5-4782-a5d4-53bb8f07e226' # Which of an adapter's advanced properties are gathered and shown, matched on the registry # keyword: the display names are localised and every vendor words them differently, the # keywords are what the driver's INF calls them. Wider than what counts as power saving, # because wake and interrupt moderation are what a second-level Technician asks about next. $script:NetworkEnergySettingPattern = 'EEE|Energy|Green|PowerSav|PowerDown|LowPower|ULP|Sips|SelectiveSuspend|SSIdle|DeviceSleep|GigaLite|AutoDisableGigabit|Wake|Wol|PME|Moderation|^ITR$' # Which of those save energy while the machine is running, and so can take the link down # under a program that is using it. The default of the PowerSavingKeywordPattern parameter. # A keyword that matches is counted only when it is a switch; see Test-AdapterSettingSwitch. $script:NetworkPowerSavingPattern = 'EEE|EnergyEfficient|Green|PowerSav|LowPower|ULPMode|SipsEnabled|SelectiveSuspend|DeviceSleep|GigaLite|AutoDisableGigabit' # A keyword that names a list of modes and not a switch, where the driver did not say which # values the setting can take. $script:NetworkModeKeywordPattern = '(?:Mode|Level)$' # What the SMB client configuration is asked for. The rest of it concerns signing and # guests, which is another Check's question. $script:NetworkSmbClientProperties = @( 'SessionTimeout', 'FileInfoCacheLifetime', 'DirectoryCacheLifetime', 'OplocksDisabled', 'EnableBandwidthThrottling', 'EnableMultiChannel' ) # The power management answers that say Windows may take the adapter away to save energy. $script:NetworkPowerManagementSavers = @('AllowComputerToTurnOffDevice', 'SelectiveSuspend', 'DeviceSleepOnDisconnect') $script:NetworkPowerManagementProperties = $script:NetworkPowerManagementSavers + @('WakeOnMagicPacket', 'WakeOnPattern', 'ArpOffload', 'NSOffload', 'D0PacketCoalescing', 'RsnRekeyOffload') # The user's proxy settings, as Internet Options writes them. $script:NetworkUserProxyKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings' function Get-NetworkData { <# .PARAMETER Observed What earlier Checks gathered, the Elevated Part's among them. The adapters' power management is closed to a Run without admin rights; the NetworkDisruption Check reads it with them and it is taken from there. See Get-NetworkAdapterFact. #> [CmdletBinding()] [OutputType([psobject])] param( [hashtable]$Parameters = @{}, [AllowNull()][hashtable]$Observed = @{} ) $elevatedPower = $null if ($Observed -and $Observed.ContainsKey('NetworkDisruption')) { $elevatedPower = Get-DataProperty $Observed['NetworkDisruption'] 'AdapterPowerManagement' } $adapterFacts = Get-NetworkAdapterFact -Parameters $Parameters -ElevatedPowerManagement $elevatedPower $adapters = $adapterFacts.Adapters # 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 # USB selective suspend in the active power plan: $true, $false, or $null when # powercfg gave no answer. The text is what it printed, for the Section. UsbSelectiveSuspend = $adapterFacts.UsbSelectiveSuspend UsbSelectiveSuspendText = $adapterFacts.UsbSelectiveSuspendText SmbClientConfiguration = $adapterFacts.SmbClientConfiguration # The sources that would not answer, by the name a Technician can type. Not read # is not the same as absent, and only the Gatherer can tell the two apart. Unread = $adapterFacts.Unread # 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 Get-NetworkAdapterPowerManagement { <# .SYNOPSIS Get-NetAdapterPowerManagement for every adapter, as rows of text, and whether it refused. Never throws, and writes no error into the transcript. .DESCRIPTION Asked for all adapters at once: a virtual adapter has no power management, and asked for by name it answers with an error that says nothing about this Run's rights. Nothing back at all, with an error, is the refusal - and without admin rights that is what comes back. Called twice in a Run that was given admin rights: by the Network Check, which runs with the user's rights and is refused, and by the NetworkDisruption Check in the Elevated Part, whose answer the Network Check then uses. #> [CmdletBinding()] [OutputType([psobject])] param() $rows = @() $refused = $true if (Get-Command -Name Get-NetAdapterPowerManagement -ErrorAction SilentlyContinue) { $problems = $null $power = @(Get-NetAdapterPowerManagement -ErrorAction SilentlyContinue -ErrorVariable problems) $refused = [bool](-not $power.Count -and @($problems).Count) $rows = @(foreach ($row in $power) { $out = [pscustomobject]@{ Name = "$($row.Name)" } foreach ($property in $script:NetworkPowerManagementProperties) { $out | Add-Member -NotePropertyName $property -NotePropertyValue "$($row.$property)" } $out }) } [pscustomobject]@{ Rows = $rows; Refused = $refused } } function Select-AdapterPowerManagement { <# .SYNOPSIS Which reading of the adapters' power management a Run uses: its own, or the Elevated Part's where its own was refused. Pure. .DESCRIPTION Seen on a real machine: a Run that was given admin rights still reported "nicht gelesen: Get-NetAdapterPowerManagement", because the Check that asks runs with the user's rights and nobody handed it what the Elevated Part could read. Unread only when neither has rows: refused here, and no Elevated Part, or one that was refused as well or came from a module that did not read it yet. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()]$Own, [AllowNull()]$Elevated) if (-not [bool](Get-DataProperty $Own 'Refused')) { return [pscustomobject]@{ Rows = (Get-DataCollection $Own 'Rows'); Unread = $false; Source = 'Own' } } $rows = @((Get-DataCollection ([pscustomobject]@{ Rows = $Elevated }) 'Rows') | Where-Object { $_ }) [pscustomobject]@{ Rows = $rows; Unread = [bool](-not $rows.Count); Source = $(if ($rows.Count) { 'Elevated' } else { 'None' }) } } function Get-NetworkAdapterFact { <# .SYNOPSIS The adapters that are up with their driver, whether they are USB devices and what their power saving is set to, USB selective suspend and the SMB client configuration. .DESCRIPTION Every source is asked once and may refuse: Get-NetAdapterPowerManagement does so without admin rights. A refusal is an answer here, so nothing is asked with -ErrorAction Stop - that would write a TerminatingError line into the Run's transcript for a setting this Run was never going to read. What refused is named in Unread. #> [CmdletBinding()] [OutputType([psobject])] param( [hashtable]$Parameters = @{}, # What the Elevated Part read of the adapters' power management, when it ran. Used # where this Run's own rights were refused. [AllowNull()]$ElevatedPowerManagement ) $unread = [System.Collections.Generic.List[string]]::new() $up = @() if (Get-Command -Name Get-NetAdapter -ErrorAction SilentlyContinue) { $problems = $null $up = @(Get-NetAdapter -ErrorAction SilentlyContinue -ErrorVariable problems | Where-Object { $_.Status -eq 'Up' }) if (@($problems).Count) { $unread.Add('Get-NetAdapter') } } else { $unread.Add('Get-NetAdapter') } $power = @() $advanced = @() $sleep = @() if ($up.Count) { # Refused without admin rights. A Run that was given them read it in the Elevated # Part, and that answer is as good as one read here. $chosen = Select-AdapterPowerManagement -Own (Get-NetworkAdapterPowerManagement) -Elevated $ElevatedPowerManagement $power = @($chosen.Rows) if ($chosen.Unread) { $unread.Add('Get-NetAdapterPowerManagement') } if (Get-Command -Name Get-NetAdapterAdvancedProperty -ErrorAction SilentlyContinue) { $problems = $null $advanced = @(Get-NetAdapterAdvancedProperty -ErrorAction SilentlyContinue -ErrorVariable problems) if (-not $advanced.Count -and @($problems).Count) { $unread.Add('Get-NetAdapterAdvancedProperty') } } else { $unread.Add('Get-NetAdapterAdvancedProperty') } # "Allow the computer to turn off this device to save power" as Device Manager # shows it, one instance per device that has the tick box. Readable without admin # rights where Get-NetAdapterPowerManagement is not, which is why both are asked. $problems = $null $sleep = @(Get-CimInstance -Namespace 'root\wmi' -ClassName 'MSPower_DeviceEnable' -ErrorAction SilentlyContinue -ErrorVariable problems) if (-not $sleep.Count -and @($problems).Count) { $unread.Add('MSPower_DeviceEnable') } } $energyPattern = Get-Parameter $Parameters 'EnergySettingPattern' $script:NetworkEnergySettingPattern $savingPattern = Get-Parameter $Parameters 'PowerSavingKeywordPattern' $script:NetworkPowerSavingPattern $adapters = @($up | ForEach-Object { $name = "$($_.Name)" $pnp = "$($_.PnPDeviceID)" $management = $null # Read with Get-DataProperty: a row from the Elevated Part came through CliXML. $row = $power | Where-Object { "$(Get-DataProperty $_ 'Name')" -eq $name } | Select-Object -First 1 if ($row) { $management = [pscustomobject]@{} foreach ($property in $script:NetworkPowerManagementProperties) { $management | Add-Member -NotePropertyName $property -NotePropertyValue "$(Get-DataProperty $row $property)" } } # The instance is named after the device: its PnP device id and an index. $sleepAllowed = $null if ($pnp) { $instance = $sleep | Where-Object { "$($_.InstanceName)" -like ('{0}_*' -f [WildcardPattern]::Escape($pnp)) } | Select-Object -First 1 if ($instance) { $sleepAllowed = [bool]$instance.Enable } } # By keyword where the driver gave one; by display name only for the property # that has none. $settings = @($advanced | Where-Object { "$($_.Name)" -eq $name } | Where-Object { if ("$($_.RegistryKeyword)") { "$($_.RegistryKeyword)" -match $energyPattern } else { "$($_.DisplayName)" -match $energyPattern } } | ForEach-Object { [pscustomobject]@{ Keyword = "$($_.RegistryKeyword)" DisplayName = "$($_.DisplayName)" Value = "$($_.DisplayValue)" RegistryValue = (@($_.RegistryValue) -join ',') # What the driver says the setting can be, in its order. Empty for a # setting that takes a number from a range. It is what tells a switch # from a list of modes. ValidRegistryValues = @($_.ValidRegistryValues | Where-Object { $null -ne $_ } | ForEach-Object { "$_" }) } }) [pscustomobject]@{ Name = $name Description = "$($_.InterfaceDescription)" LinkSpeed = "$($_.LinkSpeed)" DriverVersion = "$($_.DriverVersion)" DriverDate = "$($_.DriverDate)" # A USB network adapter - in a dock, in a dongle, on a stick - is enumerated by # the USB bus, and its PnP device id says so. Kept beside the answer. PnpDeviceId = $pnp IsUsb = $pnp -like 'USB\*' PowerManagement = $management DeviceSleepAllowed = $sleepAllowed EnergySettings = $settings # A reduction of the three above and nothing more, so the Report can state one # answer per adapter; Get-AdapterPowerSaving is pure and says how. PowerSavingOn = Get-AdapterPowerSaving -PowerManagement $management -DeviceSleepAllowed $sleepAllowed ` -EnergySetting $settings -KeywordPattern $savingPattern } }) $suspendText = Get-PowercfgSettingText -Subgroup $script:NetworkUsbPowerSubgroup -Setting $script:NetworkUsbSuspendSetting $suspend = ConvertFrom-PowercfgIndexText -Text $suspendText if (-not $suspend.Recognised) { $unread.Add('powercfg') } $smb = $null if (Get-Command -Name Get-SmbClientConfiguration -ErrorAction SilentlyContinue) { $problems = $null $configuration = Get-SmbClientConfiguration -ErrorAction SilentlyContinue -ErrorVariable problems if ($configuration) { $smb = [pscustomobject]@{} foreach ($property in $script:NetworkSmbClientProperties) { $smb | Add-Member -NotePropertyName $property -NotePropertyValue $configuration.$property } } } if ($null -eq $smb) { $unread.Add('Get-SmbClientConfiguration') } [pscustomobject]@{ Adapters = $adapters UsbSelectiveSuspend = $suspend.On UsbSelectiveSuspendText = $suspendText SmbClientConfiguration = $smb Unread = @($unread) } } function Get-PowercfgSettingText { <# .SYNOPSIS What "powercfg /q" prints for one setting of the active power plan, or an empty string when it would not say. #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)][string]$Subgroup, [Parameter(Mandatory)][string]$Setting ) if (-not (Get-Command -Name 'powercfg.exe' -CommandType Application -ErrorAction SilentlyContinue)) { return '' } # A power plan without the setting makes powercfg complain on stderr. Under the # module's Stop that complaint would be a terminating error, and a machine without the # setting is not one. $ErrorActionPreference = 'SilentlyContinue' $output = & powercfg.exe /q SCHEME_CURRENT $Subgroup $Setting 2>$null if ($LASTEXITCODE -ne 0) { return '' } ($output | Out-String) } function ConvertFrom-PowercfgIndexText { <# .SYNOPSIS Parses what "powercfg /q" printed for one setting into the index in effect on mains and the one on battery. Pure, and reaches nothing. .DESCRIPTION powercfg prints its labels in the UI language, so this reads the two values written as 0x and eight hex digits and nothing else: the possible settings above them are written as three plain digits. Mains comes first, battery second. Anything that does not hold both is not recognised, because an error read as "off" would be a false all-clear. On says whether the setting is switched on for either: a notebook on a dock is on mains at the desk and on battery in the meeting room, and the adapter goes with it. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyString()][string]$Text) $found = [regex]::Matches("$Text", '(?m):\s*0x([0-9a-fA-F]{8})\s*$') $ac = $null $dc = $null $on = $null if ($found.Count -ge 2) { $ac = [Convert]::ToInt32($found[0].Groups[1].Value, 16) $dc = [Convert]::ToInt32($found[1].Groups[1].Value, 16) $on = ($ac -ne 0) -or ($dc -ne 0) } [pscustomobject]@{ PSTypeName = 'Gutcheck.PowercfgIndex' Recognised = $found.Count -ge 2 AcIndex = $ac DcIndex = $dc On = $on } } function Test-AdapterSettingSwitch { <# .SYNOPSIS Whether an advanced property is a plain switch: 0 is off, 1 is on, and it can be nothing else. Pure. .DESCRIPTION Only a switch can be reduced to on or off without knowing the driver. A setting with more positions is a list of modes, and which of them saves energy is the vendor's to say: an Intel wireless adapter's MIMOPowerSaveMode is 3 when the saving is switched off, and reading every value but 0 as "on" called that power saving. The driver's own list of valid values decides, where it gave one: Intel's ULPMode is called a mode and lists 1 and 0. Data without the list - gathered before it was, or a setting that takes a number from a range - is judged by its name and its value: a keyword that ends in Mode or Level is taken for a list of modes, and anything else for a switch only while its value is 0 or 1. #> [CmdletBinding()] [OutputType([bool])] param([AllowNull()]$Setting) $valid = (Get-DataCollection $Setting 'ValidRegistryValues') if ($valid.Count) { foreach ($value in $valid) { $position = ConvertTo-Number $value if ($null -eq $position -or ($position -ne 0 -and $position -ne 1)) { return $false } } return $true } if ("$(Get-DataProperty $Setting 'Keyword')" -match $script:NetworkModeKeywordPattern) { return $false } $number = ConvertTo-Number (Get-DataProperty $Setting 'RegistryValue') ($number -eq 0) -or ($number -eq 1) } function Get-AdapterPowerSaving { <# .SYNOPSIS Whether an adapter's settings let it save energy while the machine runs: $true, $false, or $null when none of them gave an answer. Pure. .DESCRIPTION Three sources, any of which may be missing. One that says "on" decides it; "off" needs at least one that answered and none that said on. "Unsupported", an empty value and a setting that is not a number are not answers. Wake-on-LAN and interrupt moderation are gathered and shown but not counted: they do not take a link down under a program that is using it. Neither is a setting that is not a switch, whatever its keyword: Test-AdapterSettingSwitch says why. #> [CmdletBinding()] [OutputType([bool])] param( [AllowNull()]$PowerManagement, [AllowNull()]$DeviceSleepAllowed, [AllowNull()][AllowEmptyCollection()]$EnergySetting, [string]$KeywordPattern = $script:NetworkPowerSavingPattern ) $on = $false $off = $false if ($DeviceSleepAllowed -is [bool]) { if ($DeviceSleepAllowed) { $on = $true } else { $off = $true } } foreach ($name in $script:NetworkPowerManagementSavers) { $answer = "$(Get-DataProperty $PowerManagement $name)" if ($answer -eq 'Enabled') { $on = $true } elseif ($answer -eq 'Disabled') { $off = $true } } foreach ($setting in @($EnergySetting)) { if ($null -eq $setting) { continue } if ("$(Get-DataProperty $setting 'Keyword')" -notmatch $KeywordPattern) { continue } if (-not (Test-AdapterSettingSwitch -Setting $setting)) { continue } $number = ConvertTo-Number (Get-DataProperty $setting 'RegistryValue') if ($number -eq 1) { $on = $true } elseif ($number -eq 0) { $off = $true } } if ($on) { return $true } if ($off) { return $false } $null } function ConvertTo-NetworkFinding { [CmdletBinding()] [OutputType([psobject])] param( [AllowNull()]$Data, [hashtable]$Parameters = @{} ) New-AdapterFinding -Data $Data -Parameters $Parameters New-AdapterSettingFinding -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 } New-AdapterSection -Data $Data # 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 Get-TriStateText { # Yes, no, or not read: $null is the third answer and must not be shown as either. [CmdletBinding()] param([AllowNull()]$Value) if ($null -eq $Value) { return (Get-Text 'Value.Network.NotRead') } if ($Value) { return (Get-Text 'Value.Network.Yes') } Get-Text 'Value.Network.No' } function New-AdapterSection { <# .SYNOPSIS The adapters as a table, and under it the settings each answer was reduced from, USB selective suspend and the SMB client configuration, as they came. #> [CmdletBinding()] param([AllowNull()]$Data) $adapters = (Get-DataCollection $Data 'Adapters') $notRead = Get-Text 'Value.Network.NotRead' # The headings are German like the rest of the Report, so the rows are built with the # names the table is to show. $name = Get-Text 'Column.Network.Adapter.Name' $description = Get-Text 'Column.Network.Adapter.Description' $linkSpeed = Get-Text 'Column.Network.Adapter.LinkSpeed' $driverVersion = Get-Text 'Column.Network.Adapter.DriverVersion' $driverDate = Get-Text 'Column.Network.Adapter.DriverDate' $usb = Get-Text 'Column.Network.Adapter.Usb' $powerSaving = Get-Text 'Column.Network.Adapter.PowerSaving' New-Section -Title (Get-Text 'Title.Network.Adapters') -Row ($adapters | ForEach-Object { $row = [ordered]@{} $row[$name] = Get-DataProperty $_ 'Name' $row[$description] = Get-DataProperty $_ 'Description' $row[$linkSpeed] = Get-DataProperty $_ 'LinkSpeed' $row[$driverVersion] = Get-DataProperty $_ 'DriverVersion' $row[$driverDate] = Get-DataProperty $_ 'DriverDate' $row[$usb] = Get-TriStateText (Get-DataProperty $_ 'IsUsb') $row[$powerSaving] = Get-TriStateText (Get-DataProperty $_ 'PowerSavingOn') [pscustomobject]$row }) # What is a cmdlet, a WMI class or a registry keyword stays what a Technician types # and searches for; what is said about it is German. $lines = @() foreach ($adapter in $adapters) { $lines += (Get-Text 'Value.Network.Section.Adapter') -f (Get-DataProperty $adapter 'Name'), (Get-DataProperty $adapter 'PnpDeviceId') $management = Get-DataProperty $adapter 'PowerManagement' if ($null -eq $management) { $lines += ' Get-NetAdapterPowerManagement: {0}' -f $notRead } else { foreach ($name in $script:NetworkPowerManagementProperties) { $lines += ' {0} = {1}' -f $name, (Get-DataProperty $management $name) } } $lines += ' MSPower_DeviceEnable = {0}' -f (Get-TriStateText (Get-DataProperty $adapter 'DeviceSleepAllowed')) # Every setting, the ones that are not counted as power saving included, with the # values the driver says it can take: that list is why a mode is not counted. foreach ($setting in (Get-DataCollection $adapter 'EnergySettings')) { $line = ' {0} ({1}) = {2} [{3}]' -f (Get-DataProperty $setting 'Keyword'), (Get-DataProperty $setting 'DisplayName'), (Get-DataProperty $setting 'Value'), (Get-DataProperty $setting 'RegistryValue') $valid = (Get-DataCollection $setting 'ValidRegistryValues') if ($valid.Count) { $line += ' ({0})' -f ((Get-Text 'Value.Network.Section.ValidValues') -f ($valid -join ', ')) } $lines += $line } $lines += '' } $lines += (Get-Text 'Value.Network.Section.UsbSuspend') -f (Get-TriStateText (Get-DataProperty $Data 'UsbSelectiveSuspend')) $suspendText = "$(Get-DataProperty $Data 'UsbSelectiveSuspendText')".Trim("`r", "`n") if ($suspendText) { $lines += $suspendText } $lines += '' $smb = Get-DataProperty $Data 'SmbClientConfiguration' if ($null -eq $smb) { $lines += 'Get-SmbClientConfiguration: {0}' -f $notRead } else { $lines += 'Get-SmbClientConfiguration:' foreach ($name in $script:NetworkSmbClientProperties) { $lines += ' {0} = {1}' -f $name, (Get-DataProperty $smb $name) } } New-Section -Title (Get-Text 'Title.Network.PowerSavingAndSmb') -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') } $value = '{0} @ {1}' -f $adapter.Description, $adapter.LinkSpeed if (Get-DataProperty $adapter 'IsUsb') { $value = (Get-Text 'Value.Network.AdapterUsb') -f $adapter.Description, $adapter.LinkSpeed } New-Finding -Category Network -Check ((Get-Text 'Check.Network.Adapter') -f $adapter.Name) -Severity INFO ` -Value $value -Hint $hint } } function New-AdapterSettingFinding { <# .SYNOPSIS Names the settings this Run could not read, so that their absence from the Report is not taken for a machine without power saving. .DESCRIPTION Says nothing else. Whether a USB adapter with power saving matters depends on whether a program runs from a share, and this Check is performed before the one that finds that out; the NetworkDependency Kind weighs the two together. #> [CmdletBinding()] param([AllowNull()]$Data, [hashtable]$Parameters) $unread = (Get-DataCollection $Data 'Unread') if (-not $unread.Count) { return } New-Finding -Category Network -Check (Get-Text 'Check.Network.AdapterSettings') -Severity INFO ` -Value ((Get-Text 'Value.Network.SettingsUnread') -f ($unread -join ', ')) ` -Hint (Get-Text 'Hint.Network.SettingsUnread') } 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 = Get-Text 'Check.Network.DnsResolution' $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') } } |