Private/Kinds/NetworkProtocol.ps1
|
# The NetworkProtocol Kind: what Windows logged of a protocol failing, beyond file shares. # # The NetworkDisruption Kind reads what the SMB client and the adapter logged. A machine # talks more than SMB: it signs in to a domain, asks DNS, takes its time and its address # from a server, proves itself to a switch, and is told by Windows whether there is # internet. Each of those writes down when it fails, in a log of its own, and a complaint # nobody can reproduce on the day of the Run is as a rule in one of them. # # Which events count is data, as it is for the NetworkDisruption Kind, and read the same # way. A Check Definition's Scans parameter names them, and the Local Definitions carry # the entries verified against the event manifests (docs/research/network-protocol-events.md): # # { "Log": "System", "Provider": [ "NETLOGON" ], "Id": [ 5719 ], # "Topic": "domain", which Finding the event belongs to # "Kind": "domain-no-controller", what the Judge calls it # "SubjectIndex": 0, optional; payload position of what was affected # "SubjectType": "host", optional; how to read it: host, spn, peer, file, text # "OwnDomainOnly": true, optional; a name outside the machine's own # domain is counted and not kept. The Local # Definitions do not ask for it: every name is # named (ADR-0006, amended 2026-10-12) # "CodeIndex": 1, "CodeType": "win32", optional; a code and whose it is: win32, ras, # tls-alert, ncsi, text # "CodePattern": "Reason: (\\w+)", optional; the code out of the message instead # "Match": { "5": [ 2 ] }, optional; a payload field must hold one of these # "Except": { "3": [ 631 ] }, optional; a payload field must hold none of these # "KeepMessage": false } optional; Windows' message is not kept. The # Local Definitions keep every message # # A topic is one of dns, domain, time, dhcp, dot1x, tls, internet and vpn: the wording of # its Finding is written for it in Private/Text.ps1. How many events make a warning is the # Topics parameter, per topic: # # "Topics": { "domain": { "WarnAbove": 10, # "WarnKinds": [ "domain-trust-broken" ], warn from the first # "AwayKinds": [ "domain-no-controller" ] } } expected on a notebook # away from its network # # Two things are not in a log and are read beside it, because they are what the logged # events are about: how far this clock is from its time source, measured, and what # Windows says of the internet at the moment of the Run. # # What the Gatherer returns: # Days, Since, UnreadableLogs, ScannedLogs, LogCoverage as the NetworkDisruption Kind # Events { Time, Provider, Id, Topic, Kind, Subject, Withheld, Cause, Message }, newest first # Clock { Type, Source, FromDomain, Answered, OffsetSeconds } # Internet { Networks = { Alias, Connectivity }, ActiveProbeOff } $script:NetworkProtocolDefaultDays = 30 # The tag these events carry in events.csv. $script:NetworkProtocolEventTag = 'Network protocol' # How many of the newest events the Section lists. events.csv holds all of them. $script:NetworkProtocolSectionRows = 200 # Above how many events of a topic its Finding is a warning, where a Check Definition # does not say. $script:NetworkProtocolWarnAbove = 20 # The topics of which nothing is said while their log is quiet: most machines have no # VPN of Windows' own and no 802.1X at the cable, and "none" would be a line about # something that is not there. $script:NetworkProtocolSilentTopics = @('vpn', 'dot1x') function ConvertTo-NetworkProtocolFieldMap { <# .SYNOPSIS A scan's Match or Except as payload position to the values it names. Pure. #> [CmdletBinding()] [OutputType([hashtable])] param([AllowNull()]$Map) $result = @{} if ($null -eq $Map) { return $result } $pairs = @() if ($Map -is [hashtable]) { $pairs = @($Map.Keys | ForEach-Object { [pscustomobject]@{ Name = "$_"; Value = $Map[$_] } }) } else { $pairs = @($Map.PSObject.Properties | ForEach-Object { [pscustomobject]@{ Name = "$($_.Name)"; Value = $_.Value } }) } foreach ($pair in $pairs) { $index = ConvertTo-Number $pair.Name if ($null -eq $index) { continue } $result["$([int]$index)"] = [string[]]@(@($pair.Value) | ForEach-Object { "$_".Trim() }) } $result } function ConvertTo-NetworkProtocolScan { <# .SYNOPSIS Reads one entry of the Scans parameter. Pure, and reaches nothing. .DESCRIPTION Log, provider, ids and token are read as the NetworkDisruption Kind reads them. A broken entry comes back with a Problem, so the Report can name a mistyped Definition instead of reporting a quiet network. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()]$Entry) $base = ConvertTo-NetworkDisruptionScan -Entry $Entry $topic = "$(Get-DataProperty $Entry 'Topic')".Trim().ToLowerInvariant() $subjectIndex = ConvertTo-Number (Get-DataProperty $Entry 'SubjectIndex') if ($null -ne $subjectIndex) { $subjectIndex = [int]$subjectIndex } $codeIndex = ConvertTo-Number (Get-DataProperty $Entry 'CodeIndex') if ($null -ne $codeIndex) { $codeIndex = [int]$codeIndex } $subjectType = "$(Get-DataProperty $Entry 'SubjectType')".Trim().ToLowerInvariant() if (-not $subjectType) { $subjectType = 'host' } # Kept unless the Definition says otherwise: "false" is the answer that matters. $keep = Get-DataProperty $Entry 'KeepMessage' $keepMessage = -not ($null -ne $keep -and "$keep" -match '^(?i)(false|0|no)$') $problem = $base.Problem if (-not $problem -and -not $topic) { $problem = 'NoTopic' } [pscustomobject]@{ Log = $base.Log Provider = $base.Provider Id = $base.Id Kind = $base.Kind Topic = $topic SubjectIndex = $subjectIndex SubjectType = $subjectType OwnDomainOnly = ("$(Get-DataProperty $Entry 'OwnDomainOnly')" -match '^(?i)(true|1|yes)$') CodeIndex = $codeIndex CodeType = "$(Get-DataProperty $Entry 'CodeType')".Trim().ToLowerInvariant() CodePattern = "$(Get-DataProperty $Entry 'CodePattern')" Match = ConvertTo-NetworkProtocolFieldMap -Map (Get-DataProperty $Entry 'Match') Except = ConvertTo-NetworkProtocolFieldMap -Map (Get-DataProperty $Entry 'Except') KeepMessage = $keepMessage Problem = $problem } } function Get-NetworkProtocolScan { <# .SYNOPSIS Every entry of a Check Definition's Scans parameter, read. Pure. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][hashtable]$Parameters) foreach ($entry in @(Get-Parameter $Parameters 'Scans' @())) { if ($null -ne $entry) { ConvertTo-NetworkProtocolScan -Entry $entry } } } function Test-NetworkProtocolPayload { <# .SYNOPSIS Whether an event's payload is what a scan's Match and Except ask for. Pure. .DESCRIPTION One id means several things by what a field holds: Windows' judgement of the internet is one event whether it rose or fell, and a VPN that ended is one event whether the line dropped or the user hung up. #> [CmdletBinding()] [OutputType([bool])] param([Parameter(Mandatory)]$LogEntry, [Parameter(Mandatory)]$Scan) foreach ($key in @($Scan.Match.Keys)) { $value = Get-EventPayload -LogEntry $LogEntry -Index ([int]$key) if ($null -eq $value -or @($Scan.Match[$key]) -notcontains "$value".Trim()) { return $false } } foreach ($key in @($Scan.Except.Keys)) { $value = Get-EventPayload -LogEntry $LogEntry -Index ([int]$key) if ($null -ne $value -and @($Scan.Except[$key]) -contains "$value".Trim()) { return $false } } $true } function Select-NetworkProtocolScan { <# .SYNOPSIS The scan an event belongs to, or nothing when no scan asked for it. Pure. #> [CmdletBinding()] [OutputType([psobject])] param( [Parameter(Mandatory)]$LogEntry, [Parameter(Mandatory)][string]$Log, [AllowNull()][AllowEmptyCollection()]$Scan ) foreach ($entry in @($Scan | Where-Object { $_ -and -not $_.Problem })) { if ($entry.Log -ne $Log) { continue } if (@($entry.Id) -notcontains [int]$LogEntry.Id) { continue } if (@($entry.Provider).Count -and @($entry.Provider) -notcontains "$($LogEntry.ProviderName)") { continue } if (-not (Test-NetworkProtocolPayload -LogEntry $LogEntry -Scan $entry)) { continue } return $entry } } function ConvertTo-NetworkProtocolSubject { <# .SYNOPSIS What an event is about, out of the payload field that names it. Pure. .DESCRIPTION host a Server, as the NetworkDisruption Kind reads one spn a service principal name, "cifs/fs01.contoso.example": the host in it peer a time source as the time service writes it, "ntp.contoso.example,0x9 (...)" file a program by its path: the file's name text as it is written, shortened #> [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyString()][string]$Value, [string]$Type = 'host') $text = "$Value".Trim() if (-not $text) { return '' } switch ($Type) { 'spn' { $parts = @($text -split '/') if ($parts.Count -gt 1) { $text = $parts[1] } $text = ($text -split '[:@]')[0] return "$(ConvertTo-NetworkDisruptionServer -Value $text)" } 'peer' { $text = ($text -split '[\s,(]')[0] return "$(ConvertTo-NetworkDisruptionServer -Value $text)" } 'file' { return "$(@($text -split '[\\/]')[-1])".Trim() } 'text' { $text = ($text -replace '\s+', ' ') if ($text.Length -gt 80) { $text = $text.Substring(0, 80).TrimEnd() + ' [...]' } return $text } } "$(ConvertTo-NetworkDisruptionServer -Value $text)" } function Test-NetworkProtocolOwnName { <# .SYNOPSIS Whether a name belongs to the machine's own network: a name without a domain, or one under a DNS suffix the machine itself carries. Pure. .DESCRIPTION A name that DNS did not answer for is, more often than a Server, a website. A Check Definition that does not want those named asks for it (OwnDomainOnly); the names of the machine's own domain are the Servers a Technician is asked about. #> [CmdletBinding()] [OutputType([bool])] param([AllowNull()][AllowEmptyString()][string]$Name, [AllowNull()][AllowEmptyCollection()][string[]]$Suffix) $text = "$Name".Trim().TrimEnd('.').ToLowerInvariant() if (-not $text) { return $false } if ($text -notmatch '\.') { return $true } foreach ($entry in @($Suffix | Where-Object { $_ })) { $own = "$entry".Trim().Trim('.').ToLowerInvariant() if (-not $own) { continue } if ($text -eq $own -or $text.EndsWith('.' + $own)) { return $true } } $false } function Get-NetworkProtocolCause { <# .SYNOPSIS The code an event carries with what it means behind it, or the text it gives as its reason. Empty where the scan names none. .DESCRIPTION Never the code alone where anything is known of it: Windows' own sentence for an error number of Windows or of its dial-up networking, the standard's word for a TLS alert. #> [CmdletBinding()] [OutputType([string])] param([Parameter(Mandatory)]$LogEntry, [Parameter(Mandatory)]$Scan) $raw = $null if ($Scan.CodePattern) { $found = [regex]::Match("$($LogEntry.Message)", $Scan.CodePattern) if ($found.Success -and $found.Groups.Count -gt 1) { $raw = $found.Groups[1].Value } } elseif ($null -ne $Scan.CodeIndex) { $raw = Get-EventPayload -LogEntry $LogEntry -Index $Scan.CodeIndex } $raw = "$raw".Trim() if (-not $raw) { return '' } switch ($Scan.CodeType) { 'win32' { # Netlogon writes the number as a reference to a message: "%%1311". $number = $raw -replace '^%+', '' $meaning = "$(Get-Win32ErrorText -Code $number)".Trim() # Of a long sentence the first: the rest is advice to the person signing in. if ($meaning.Length -gt 120) { $end = $meaning.IndexOf('. ') if ($end -gt 20) { $meaning = $meaning.Substring(0, $end) } } return (Format-CodeWithMeaning -Code $number -Meaning $meaning.TrimEnd('.')) } 'ras' { return (Format-CodeWithMeaning -Code $raw -Meaning (Get-RasErrorText -Code $raw)) } 'tls-alert' { return (Format-CodeWithMeaning -Code $raw -Meaning (Get-TlsAlertText -Alert $raw)) } 'ncsi' { return (Format-CodeWithMeaning -Code $raw -Meaning (Get-NetworkProtocolInternetReasonText -Reason $raw)) } 'text' { $text = ($raw -replace '\s+', ' ') if ($text.Length -gt 200) { $text = $text.Substring(0, 200).TrimEnd() + ' [...]' } return $text } } $raw } function Get-NetworkProtocolInternetReasonText { <# .SYNOPSIS What Windows' reason for changing its judgement of the internet means. Nothing for a reason not known here. Pure. .DESCRIPTION The names are Windows' own, as the event renders them in every language. Seen on Windows 11; not a table Microsoft documents. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyString()][string]$Reason) switch ("$Reason".Trim()) { 'SuspectDnsProbeFailed' { return (Get-Text 'Value.NetworkProtocol.Reason.DnsProbe') } 'SuspectArpProbeFailed' { return (Get-Text 'Value.NetworkProtocol.Reason.ArpProbe') } 'NoRoute' { return (Get-Text 'Value.NetworkProtocol.Reason.NoRoute') } 'ActiveHttpProbeFailed' { return (Get-Text 'Value.NetworkProtocol.Reason.HttpProbe') } 'ActiveDnsProbeFailed' { return (Get-Text 'Value.NetworkProtocol.Reason.DnsProbe') } 'CapabilityReset' { return (Get-Text 'Value.NetworkProtocol.Reason.Reset') } } '' } function ConvertTo-NetworkProtocolRow { <# .SYNOPSIS One event as the row the Judge and the Sections read. #> [CmdletBinding()] [OutputType([psobject])] param( [Parameter(Mandatory)]$LogEntry, [Parameter(Mandatory)]$Scan, # The DNS suffixes the machine carries: what "its own domain" is. [AllowNull()][AllowEmptyCollection()][string[]]$Suffix ) $subject = '' $withheld = $false if ($null -ne $Scan.SubjectIndex) { $subject = ConvertTo-NetworkProtocolSubject -Value (Get-EventPayload -LogEntry $LogEntry -Index $Scan.SubjectIndex) -Type $Scan.SubjectType if ($subject -and $Scan.OwnDomainOnly -and -not (Test-NetworkProtocolOwnName -Name $subject -Suffix $Suffix)) { $subject = '' $withheld = $true } } $row = ConvertTo-EventRow -LogEntry $LogEntry $message = $row.Message # Windows' sentence names what is withheld, or an account: then it is not kept, and # the row says what happened through its kind, its subject and its cause. if ($withheld -or -not $Scan.KeepMessage) { $message = '' } [pscustomobject]@{ Time = $row.Time Provider = $row.Provider Id = $row.Id Topic = $Scan.Topic Kind = $Scan.Kind Subject = $subject Withheld = $withheld Cause = Get-NetworkProtocolCause -LogEntry $LogEntry -Scan $Scan Message = $message } } function Get-NetworkProtocolSuffix { <# .SYNOPSIS The DNS suffixes this machine carries: its domain, its search list, and what its connections were given. Never throws. #> [CmdletBinding()] [OutputType([string])] param() $found = New-Object 'System.Collections.Generic.List[string]' try { $system = Get-CimInstance Win32_ComputerSystem -ErrorAction Stop if ($system.PartOfDomain -and $system.Domain) { $found.Add("$($system.Domain)") } } catch { } try { if (Get-Command -Name Get-DnsClientGlobalSetting -ErrorAction SilentlyContinue) { foreach ($entry in @((Get-DnsClientGlobalSetting -ErrorAction Stop).SuffixSearchList)) { if ($entry) { $found.Add("$entry") } } } } catch { } try { if (Get-Command -Name Get-DnsClient -ErrorAction SilentlyContinue) { foreach ($entry in @(Get-DnsClient -ErrorAction Stop | ForEach-Object { $_.ConnectionSpecificSuffix })) { if ($entry) { $found.Add("$entry") } } } } catch { } @($found | ForEach-Object { $_.Trim().ToLowerInvariant() } | Where-Object { $_ } | Sort-Object -Unique) } function ConvertFrom-SntpResponse { <# .SYNOPSIS How far this clock is from the server's, in seconds, out of the server's answer and the two moments it was asked and answered. $null for what is no answer. Pure. .DESCRIPTION RFC 4330: the offset is ((T2 - T1) + (T3 - T4)) / 2, with T1 and T4 this clock's times of asking and of the answer, T2 and T3 the server's of receiving and of sending. Positive: this clock is behind. A server that says it has no time of its own - stratum 0 - gave no answer. #> [CmdletBinding()] [OutputType([double])] param([AllowNull()][byte[]]$Response, [datetime]$Sent, [datetime]$Received) if ($null -eq $Response -or $Response.Length -lt 48) { return $null } if (($Response[0] -band 0x07) -ne 4) { return $null } if ($Response[1] -eq 0) { return $null } $epoch = [datetime]::new(1900, 1, 1, 0, 0, 0, [DateTimeKind]::Utc) $read = { param([int]$At) $seconds = ([double]$Response[$At] * 16777216) + ([double]$Response[$At + 1] * 65536) + ([double]$Response[$At + 2] * 256) + [double]$Response[$At + 3] $fraction = ([double]$Response[$At + 4] * 16777216) + ([double]$Response[$At + 5] * 65536) + ([double]$Response[$At + 6] * 256) + [double]$Response[$At + 7] if ($seconds -eq 0) { return $null } $epoch.AddSeconds($seconds + ($fraction / 4294967296)) } $t2 = & $read 32 $t3 = & $read 40 if ($null -eq $t2 -or $null -eq $t3) { return $null } $t1 = $Sent.ToUniversalTime() $t4 = $Received.ToUniversalTime() [double]((($t2 - $t1).TotalSeconds + ($t3 - $t4).TotalSeconds) / 2) } function Get-SntpOffset { <# .SYNOPSIS Asks a time server once and returns how far this clock is from it, in seconds. $null when it does not answer. Changes nothing: the clock is read, not set. #> [CmdletBinding()] [OutputType([double])] param([Parameter(Mandatory)][string]$Server, [int]$TimeoutMs = 2000) $client = $null try { $client = New-Object System.Net.Sockets.UdpClient $client.Client.ReceiveTimeout = $TimeoutMs $client.Client.SendTimeout = $TimeoutMs $client.Connect($Server, 123) # Version 4, mode 3: a client asking. $request = New-Object byte[] 48 $request[0] = 0x23 $sent = [datetime]::UtcNow $null = $client.Send($request, $request.Length) $from = New-Object System.Net.IPEndPoint ([System.Net.IPAddress]::Any, 0) $response = $client.Receive([ref]$from) $received = [datetime]::UtcNow ConvertFrom-SntpResponse -Response $response -Sent $sent -Received $received } catch { $null } finally { if ($client) { try { $client.Close() } catch { } } } } function Select-NetworkProtocolTimeSource { <# .SYNOPSIS Where this machine takes its time from, out of how the time service is set. Pure. .DESCRIPTION Type NT5DS is the domain; NTP is the servers named; AllSync is both, and the named servers are the ones that can be asked. A server is written with flags behind a comma, "ntp.contoso.example,0x9", and several with spaces between. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyString()][string]$Type, [AllowNull()][AllowEmptyString()][string]$NtpServer, [AllowNull()][AllowEmptyString()][string]$Domain) $kind = "$Type".Trim() $manual = "$(@("$NtpServer".Trim() -split '\s+' | Where-Object { $_ })[0])" $manual = ($manual -split ',')[0].Trim() $source = '' $fromDomain = $false if ($kind -eq 'NoSync') { $source = '' } elseif ($kind -eq 'NT5DS' -or ($kind -eq 'AllSync' -and -not $manual)) { $source = "$Domain".Trim(); $fromDomain = [bool]$source } else { $source = $manual } [pscustomobject]@{ Type = $kind; Source = $source; FromDomain = $fromDomain } } function Get-NetworkProtocolClock { <# .SYNOPSIS How the time service is set, and how far this clock is from the source it names. Decides nothing, and never throws. #> [CmdletBinding()] [OutputType([psobject])] param() $type = '' $server = '' try { $settings = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\W32Time\Parameters' -ErrorAction Stop $type = "$($settings.Type)" $server = "$($settings.NtpServer)" } catch { } $domain = '' try { $system = Get-CimInstance Win32_ComputerSystem -ErrorAction Stop if ($system.PartOfDomain) { $domain = "$($system.Domain)" } } catch { } $source = Select-NetworkProtocolTimeSource -Type $type -NtpServer $server -Domain $domain $offset = $null if ($source.Source) { $offset = Get-SntpOffset -Server $source.Source } [pscustomobject]@{ Type = $source.Type Source = $source.Source FromDomain = $source.FromDomain Answered = ($null -ne $offset) OffsetSeconds = $offset } } function Get-NetworkProtocolInternetState { <# .SYNOPSIS What Windows says of the internet on each connected network, and whether its own probe is switched off. Decides nothing, and never throws. #> [CmdletBinding()] [OutputType([psobject])] param() $networks = @() try { if (Get-Command -Name Get-NetConnectionProfile -ErrorAction SilentlyContinue) { $networks = @(Get-NetConnectionProfile -ErrorAction Stop | ForEach-Object { # The better of the two: a network with internet over IPv6 only has it. $both = @("$($_.IPv4Connectivity)", "$($_.IPv6Connectivity)") $best = @('Internet', 'LocalNetwork', 'Subnet', 'NoTraffic', 'Disconnected' | Where-Object { $both -contains $_ })[0] [pscustomobject]@{ Alias = "$($_.InterfaceAlias)"; Connectivity = "$best" } }) } } catch { $networks = @() } $off = $false try { $policy = Get-ItemProperty -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\NetworkConnectivityStatusIndicator' -ErrorAction Stop if ("$($policy.NoActiveProbe)" -eq '1') { $off = $true } } catch { } try { $setting = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\NlaSvc\Parameters\Internet' -ErrorAction Stop if ("$($setting.EnableActiveProbing)" -eq '0') { $off = $true } } catch { } [pscustomobject]@{ Networks = $networks; ActiveProbeOff = $off } } function Get-NetworkProtocolData { <# .SYNOPSIS Reads the events a Check Definition names, the clock against its source, and what Windows says of the internet. Judges none of it. #> [CmdletBinding()] [OutputType([psobject])] param([hashtable]$Parameters = @{}) $days = [int](Get-Parameter $Parameters 'Days' $script:NetworkProtocolDefaultDays) $since = (Get-Date).AddDays(-$days) $scans = @(Get-NetworkProtocolScan -Parameters $Parameters) $suffixes = @(Get-NetworkProtocolSuffix) $unreadable = @{} # Not List[object]: @($x) throws on one of those. See Public/Invoke-Gutcheck.ps1. $rows = New-Object System.Collections.Generic.List[psobject] $scanned = New-Object System.Collections.Generic.List[psobject] $queries = @(Select-NetworkDisruptionQuery -Query @(Get-NetworkDisruptionQuery -Scan $scans) ` -Registration (Get-NetworkDisruptionRegistration)) foreach ($query in $queries) { if (@($scanned | Where-Object { $_ -eq $query.Log }).Count -eq 0) { $scanned.Add($query.Log) } $found = @(Get-StabilityEvent -Log $query.Log -Provider $query.Provider -Id $query.Id ` -Since $since -Unreadable $unreadable) foreach ($entry in $found) { $scan = Select-NetworkProtocolScan -LogEntry $entry -Log $query.Log -Scan $scans if ($scan) { $rows.Add((ConvertTo-NetworkProtocolRow -LogEntry $entry -Scan $scan -Suffix $suffixes)) } } } # Materialised before it is sorted, for the reason Get-StabilityData gives. $unreadableLogs = @($unreadable.Keys) $coverage = @(foreach ($log in $scanned) { if ($unreadable.ContainsKey($log)) { continue } [pscustomobject]@{ Log = $log; Oldest = Get-NetworkDisruptionLogOldest -Log $log } }) [pscustomobject]@{ PSTypeName = 'Gutcheck.Data.NetworkProtocol' Days = $days Since = $since Events = @($rows | Sort-Object Time -Descending) UnreadableLogs = @($unreadableLogs | Sort-Object) ScannedLogs = [string[]]@($scanned) LogCoverage = $coverage Clock = Get-NetworkProtocolClock Internet = Get-NetworkProtocolInternetState } } function Get-NetworkProtocolTopicText { <# .SYNOPSIS The wording of a topic: what its Finding is called, what it means, what to do, and its name in a table. Pure. .DESCRIPTION A literal Get-Text per topic and no computed key, so that the tests can hold every key to being defined and to being used. A topic a Check Definition invented is reported under its own name, with what fits every topic. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyString()][string]$Topic, [Parameter(Mandatory)][ValidateSet('Check', 'Meaning', 'Hint', 'Name')][string]$Part) switch ("$Topic|$Part") { 'dns|Check' { return (Get-Text 'Check.NetworkProtocol.Dns') } 'dns|Meaning' { return (Get-Text 'Meaning.NetworkProtocol.Dns') } 'dns|Hint' { return (Get-Text 'Hint.NetworkProtocol.Dns') } 'dns|Name' { return (Get-Text 'Value.NetworkProtocol.Topic.Dns') } 'domain|Check' { return (Get-Text 'Check.NetworkProtocol.Domain') } 'domain|Meaning' { return (Get-Text 'Meaning.NetworkProtocol.Domain') } 'domain|Hint' { return (Get-Text 'Hint.NetworkProtocol.Domain') } 'domain|Name' { return (Get-Text 'Value.NetworkProtocol.Topic.Domain') } 'time|Check' { return (Get-Text 'Check.NetworkProtocol.Time') } 'time|Meaning' { return (Get-Text 'Meaning.NetworkProtocol.Time') } 'time|Hint' { return (Get-Text 'Hint.NetworkProtocol.Time') } 'time|Name' { return (Get-Text 'Value.NetworkProtocol.Topic.Time') } 'dhcp|Check' { return (Get-Text 'Check.NetworkProtocol.Dhcp') } 'dhcp|Meaning' { return (Get-Text 'Meaning.NetworkProtocol.Dhcp') } 'dhcp|Hint' { return (Get-Text 'Hint.NetworkProtocol.Dhcp') } 'dhcp|Name' { return (Get-Text 'Value.NetworkProtocol.Topic.Dhcp') } 'dot1x|Check' { return (Get-Text 'Check.NetworkProtocol.Dot1x') } 'dot1x|Meaning' { return (Get-Text 'Meaning.NetworkProtocol.Dot1x') } 'dot1x|Hint' { return (Get-Text 'Hint.NetworkProtocol.Dot1x') } 'dot1x|Name' { return (Get-Text 'Value.NetworkProtocol.Topic.Dot1x') } 'tls|Check' { return (Get-Text 'Check.NetworkProtocol.Tls') } 'tls|Meaning' { return (Get-Text 'Meaning.NetworkProtocol.Tls') } 'tls|Hint' { return (Get-Text 'Hint.NetworkProtocol.Tls') } 'tls|Name' { return (Get-Text 'Value.NetworkProtocol.Topic.Tls') } 'internet|Check' { return (Get-Text 'Check.NetworkProtocol.Internet') } 'internet|Meaning' { return (Get-Text 'Meaning.NetworkProtocol.Internet') } 'internet|Hint' { return (Get-Text 'Hint.NetworkProtocol.Internet') } 'internet|Name' { return (Get-Text 'Value.NetworkProtocol.Topic.Internet') } 'vpn|Check' { return (Get-Text 'Check.NetworkProtocol.Vpn') } 'vpn|Meaning' { return (Get-Text 'Meaning.NetworkProtocol.Vpn') } 'vpn|Hint' { return (Get-Text 'Hint.NetworkProtocol.Vpn') } 'vpn|Name' { return (Get-Text 'Value.NetworkProtocol.Topic.Vpn') } } switch ($Part) { 'Check' { return ((Get-Text 'Check.NetworkProtocol.Other') -f "$Topic") } 'Meaning' { return (Get-Text 'Meaning.NetworkProtocol.Other') } 'Hint' { return (Get-Text 'Hint.NetworkProtocol.Other') } } "$Topic" } function Get-NetworkProtocolKindText { <# .SYNOPSIS What the Report calls a Kind token. Pure. .DESCRIPTION The token is data, as in the NetworkDisruption Kind; a Technician reads German. A token a Check Definition invented is shown as it came rather than hidden. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyString()][string]$Kind) switch ($Kind) { 'dns-timeout' { return (Get-Text 'Value.NetworkProtocol.Kind.DnsTimeout') } 'domain-no-controller' { return (Get-Text 'Value.NetworkProtocol.Kind.DomainNoController') } 'domain-trust-broken' { return (Get-Text 'Value.NetworkProtocol.Kind.DomainTrustBroken') } 'domain-controller-silent' { return (Get-Text 'Value.NetworkProtocol.Kind.DomainControllerSilent') } 'kerberos-ticket-refused' { return (Get-Text 'Value.NetworkProtocol.Kind.KerberosTicketRefused') } 'kerberos-clock' { return (Get-Text 'Value.NetworkProtocol.Kind.KerberosClock') } 'auth-failed' { return (Get-Text 'Value.NetworkProtocol.Kind.AuthFailed') } 'auth-no-protocol' { return (Get-Text 'Value.NetworkProtocol.Kind.AuthNoProtocol') } 'time-source-silent' { return (Get-Text 'Value.NetworkProtocol.Kind.TimeSourceSilent') } 'time-no-source' { return (Get-Text 'Value.NetworkProtocol.Kind.TimeNoSource') } 'time-unsynced' { return (Get-Text 'Value.NetworkProtocol.Kind.TimeUnsynced') } 'time-jump' { return (Get-Text 'Value.NetworkProtocol.Kind.TimeJump') } 'time-too-far' { return (Get-Text 'Value.NetworkProtocol.Kind.TimeTooFar') } 'dhcp-lease-lost' { return (Get-Text 'Value.NetworkProtocol.Kind.DhcpLeaseLost') } 'dhcp-no-address' { return (Get-Text 'Value.NetworkProtocol.Kind.DhcpNoAddress') } 'dhcp-refused' { return (Get-Text 'Value.NetworkProtocol.Kind.DhcpRefused') } 'dhcp-renew-failed' { return (Get-Text 'Value.NetworkProtocol.Kind.DhcpRenewFailed') } 'dhcp-conflict' { return (Get-Text 'Value.NetworkProtocol.Kind.DhcpConflict') } 'dhcp-config-failed' { return (Get-Text 'Value.NetworkProtocol.Kind.DhcpConfigFailed') } 'dhcp-autoconfig' { return (Get-Text 'Value.NetworkProtocol.Kind.DhcpAutoconfig') } 'dot1x-auth-failed' { return (Get-Text 'Value.NetworkProtocol.Kind.Dot1xAuthFailed') } 'tls-alert-received' { return (Get-Text 'Value.NetworkProtocol.Kind.TlsAlertReceived') } 'tls-alert-sent' { return (Get-Text 'Value.NetworkProtocol.Kind.TlsAlertSent') } 'tls-no-credentials' { return (Get-Text 'Value.NetworkProtocol.Kind.TlsNoCredentials') } 'tls-cert-expired' { return (Get-Text 'Value.NetworkProtocol.Kind.TlsCertExpired') } 'tls-cert-untrusted' { return (Get-Text 'Value.NetworkProtocol.Kind.TlsCertUntrusted') } 'tls-cert-name' { return (Get-Text 'Value.NetworkProtocol.Kind.TlsCertName') } 'internet-lost' { return (Get-Text 'Value.NetworkProtocol.Kind.InternetLost') } 'vpn-connect-failed' { return (Get-Text 'Value.NetworkProtocol.Kind.VpnConnectFailed') } 'vpn-dropped' { return (Get-Text 'Value.NetworkProtocol.Kind.VpnDropped') } } "$Kind" } function Format-NetworkProtocolCount { <# .SYNOPSIS Names with how often each, the most frequent first, as the items of a list: the first few, and how many more there are. Pure. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyCollection()][string[]]$Name, [int]$First = 5) $groups = @(@($Name | Where-Object { $_ }) | Group-Object | Sort-Object @{ Expression = 'Count'; Descending = $true }, Name) if (-not $groups.Count) { return '' } $items = @($groups | Select-Object -First $First | ForEach-Object { '{0} {1}x' -f $_.Name, $_.Count }) if ($groups.Count -gt $First) { $items += (Get-Text 'Value.NetworkProtocol.More') -f ($groups.Count - $First) } $items -join '; ' } function Get-NetworkProtocolTopicSetting { <# .SYNOPSIS What a Check Definition says of one topic: from how many events it warns, which kinds warn at once, and which are expected away from the network. Pure. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][hashtable]$Parameters, [AllowNull()][AllowEmptyString()][string]$Topic) $entry = Get-DataProperty (Get-Parameter $Parameters 'Topics' @{}) "$Topic" $warn = ConvertTo-Number (Get-DataProperty $entry 'WarnAbove') if ($null -eq $warn) { $warn = $script:NetworkProtocolWarnAbove } [pscustomobject]@{ WarnAbove = [double]$warn WarnKinds = [string[]]@(@(Get-DataProperty $entry 'WarnKinds') | ForEach-Object { "$_" } | Where-Object { $_ }) AwayKinds = [string[]]@(@(Get-DataProperty $entry 'AwayKinds') | ForEach-Object { "$_" } | Where-Object { $_ }) } } function New-NetworkProtocolTopicFinding { <# .SYNOPSIS The Finding of one topic: what Windows logged of it in the period, or that it logged nothing. #> [CmdletBinding()] param( [AllowNull()]$Data, [hashtable]$Parameters, [AllowNull()]$Situation, [Parameter(Mandatory)][string]$Topic, [AllowNull()][AllowEmptyCollection()]$Scan ) $check = Get-NetworkProtocolTopicText -Topic $Topic -Part Check $logs = [string[]]@($Scan | ForEach-Object { $_.Log } | Sort-Object -Unique) $rows = @((Get-DataCollection $Data 'Events') | Where-Object { "$(Get-DataProperty $_ 'Topic')" -eq $Topic }) if (-not $rows.Count) { if ($script:NetworkProtocolSilentTopics -contains $Topic) { return } # Nothing to say of a domain on a machine that is in none. if ($Topic -eq 'domain') { $joined = Select-SituationText -Situation $Situation -Fact Domain -Text @{ Joined = 'yes'; Unknown = '' } if (-not $joined) { return } } $unread = @((Get-DataCollection $Data 'UnreadableLogs') | Where-Object { $logs -contains $_ }) return New-NetworkDisruptionQuietFinding -Data $Data -Check $check -Unread $unread -Log $logs } $setting = Get-NetworkProtocolTopicSetting -Parameters $Parameters -Topic $Topic # A notebook away from its network cannot reach its domain controller or its time # source, and logs that every day: expected, and no count of it is a warning. $away = [bool](Select-SituationText -Situation $Situation -Fact Form -Text @{ Notebook = 'yes'; Unknown = '' }) $awayRows = @($rows | Where-Object { $setting.AwayKinds -contains "$(Get-DataProperty $_ 'Kind')" }) $counted = $rows.Count if ($away) { $counted = $rows.Count - $awayRows.Count } $severity = 'INFO' if ($counted -gt $setting.WarnAbove) { $severity = 'WARN' } # A kind that warns from the first event does so on a notebook too, unless it is one # of those expected away: a clock that could not be set for a day at home is no fault. if (@($rows | Where-Object { $token = "$(Get-DataProperty $_ 'Kind')" $setting.WarnKinds -contains $token -and -not ($away -and $setting.AwayKinds -contains $token) }).Count) { $severity = 'WARN' } $times = @($rows | ForEach-Object { ConvertTo-DataTime (Get-DataProperty $_ 'Time') } | Where-Object { $null -ne $_ } | Sort-Object) $value = @(Get-EventCountText -Count $rows.Count -Data $Data) if ($times.Count) { $onDays = @($times | ForEach-Object { $_.Date } | Sort-Object -Unique).Count $value = @((Get-Text 'Value.NetworkProtocol.Spread') -f $value[0], $onDays, $times[-1]) } $value += (Get-Text 'Value.NetworkProtocol.Kinds') -f (Format-NetworkProtocolCount -First 6 -Name @( $rows | ForEach-Object { Get-NetworkProtocolKindText -Kind "$(Get-DataProperty $_ 'Kind')" })) $causes = Format-NetworkProtocolCount -First 3 -Name @($rows | ForEach-Object { "$(Get-DataProperty $_ 'Cause')" }) if ($causes) { $value += (Get-Text 'Value.NetworkProtocol.Causes') -f $causes } $subjects = Format-NetworkProtocolCount -First 5 -Name @($rows | ForEach-Object { "$(Get-DataProperty $_ 'Subject')" }) $withheld = @($rows | Where-Object { Get-DataProperty $_ 'Withheld' }).Count if ($withheld) { $hidden = (Get-Text 'Value.NetworkProtocol.Withheld') -f $withheld if ($subjects) { $subjects = '{0}; {1}' -f $subjects, $hidden } else { $subjects = $hidden } } if ($subjects) { $value += (Get-Text 'Value.NetworkProtocol.Subjects') -f $subjects } $meaning = @(Get-NetworkProtocolTopicText -Topic $Topic -Part Meaning) if ($away -and $awayRows.Count) { $meaning += Get-Text 'Meaning.NetworkProtocol.Away' } New-Finding -Category Network -Check $check -Severity $severity ` -Value ($value -join ' | ') ` -Meaning (@($meaning | Where-Object { $_ }) -join ' | ') ` -Hint (Get-NetworkProtocolTopicText -Topic $Topic -Part Hint) ` -Reference @(New-Reference -Section 'Title.NetworkProtocol.Events') } function New-NetworkProtocolClockFinding { <# .SYNOPSIS How far this clock is from its time source. Nothing where that was not gathered. .DESCRIPTION Kerberos refuses a ticket from a clock more than five minutes off, and that is where it fails. A source that does not answer is not a clock that is wrong. #> [CmdletBinding()] param([AllowNull()]$Data, [hashtable]$Parameters) $clock = Get-DataProperty $Data 'Clock' if ($null -eq $clock) { return } $check = Get-Text 'Check.NetworkProtocol.Clock' $source = "$(Get-DataProperty $clock 'Source')".Trim() if (-not $source) { return New-Finding -Category Network -Check $check -Severity INFO ` -Value (Get-Text 'Value.NetworkProtocol.Clock.NoSource') ` -Meaning (Get-Text 'Meaning.NetworkProtocol.Clock.NoSource') ` -Hint (Get-Text 'Hint.NetworkProtocol.Clock.NoSource') } $named = $source if (Get-DataProperty $clock 'FromDomain') { $named = (Get-Text 'Value.NetworkProtocol.Clock.Domain') -f $source } $offset = ConvertTo-Number (Get-DataProperty $clock 'OffsetSeconds') if ($null -eq $offset) { return New-Finding -Category Network -Check $check -Severity INFO ` -Value ((Get-Text 'Value.NetworkProtocol.Clock.NoAnswer') -f $named) ` -Meaning (Get-Text 'Meaning.NetworkProtocol.Clock.NoAnswer') ` -Hint (Get-Text 'Hint.NetworkProtocol.Clock.NoAnswer') } $warn = [double](Get-Parameter $Parameters 'ClockWarnSeconds' 60) $fail = [double](Get-Parameter $Parameters 'ClockFailSeconds' 300) $severity = Get-Severity ([math]::Abs([double]$offset)) $warn $fail $direction = Get-Text 'Value.NetworkProtocol.Clock.Behind' if ([double]$offset -lt 0) { $direction = Get-Text 'Value.NetworkProtocol.Clock.Ahead' } $value = (Get-Text 'Value.NetworkProtocol.Clock.Offset') -f ([math]::Abs([double]$offset)), $direction, $named if ($severity -eq 'OK') { return New-Finding -Category Network -Check $check -Severity OK -Value $value } New-Finding -Category Network -Check $check -Severity $severity -Value $value ` -Meaning (Get-Text 'Meaning.NetworkProtocol.Clock.Offset') ` -Hint (Get-Text 'Hint.NetworkProtocol.Clock.Offset') } function Get-NetworkProtocolConnectivityText { <# .SYNOPSIS Windows' judgement of a network, in the Report's words. Pure. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyString()][string]$Connectivity) switch ("$Connectivity") { 'Internet' { return (Get-Text 'Value.NetworkProtocol.Connectivity.Internet') } 'LocalNetwork' { return (Get-Text 'Value.NetworkProtocol.Connectivity.Local') } 'Subnet' { return (Get-Text 'Value.NetworkProtocol.Connectivity.Subnet') } 'NoTraffic' { return (Get-Text 'Value.NetworkProtocol.Connectivity.NoTraffic') } } "$Connectivity" } function New-NetworkProtocolInternetFinding { <# .SYNOPSIS What Windows says of the internet now. Nothing where no network is connected or that was not gathered. #> [CmdletBinding()] param([AllowNull()]$Data) $state = Get-DataProperty $Data 'Internet' $networks = @((Get-DataCollection $state 'Networks') | Where-Object { "$(Get-DataProperty $_ 'Connectivity')" -and "$(Get-DataProperty $_ 'Connectivity')" -ne 'Disconnected' }) if (-not $networks.Count) { return } $check = Get-Text 'Check.NetworkProtocol.InternetState' $named = @($networks | ForEach-Object { '{0}: {1}' -f "$(Get-DataProperty $_ 'Alias')", (Get-NetworkProtocolConnectivityText -Connectivity "$(Get-DataProperty $_ 'Connectivity')") }) -join '; ' $value = @((Get-Text 'Value.NetworkProtocol.InternetState') -f $named) $off = [bool](Get-DataProperty $state 'ActiveProbeOff') if ($off) { $value += Get-Text 'Value.NetworkProtocol.InternetState.ProbeOff' } $online = @($networks | Where-Object { "$(Get-DataProperty $_ 'Connectivity')" -eq 'Internet' }).Count if (-not $online) { return New-Finding -Category Network -Check $check -Severity WARN -Value ($value -join ' | ') ` -Meaning (Get-Text 'Meaning.NetworkProtocol.InternetState.None') ` -Hint (Get-Text 'Hint.NetworkProtocol.InternetState.None') } if ($off) { return New-Finding -Category Network -Check $check -Severity INFO -Value ($value -join ' | ') ` -Meaning (Get-Text 'Meaning.NetworkProtocol.InternetState.ProbeOff') ` -Hint (Get-Text 'Hint.NetworkProtocol.InternetState.ProbeOff') } New-Finding -Category Network -Check $check -Severity OK -Value ($value -join ' | ') } function ConvertTo-NetworkProtocolFinding { [CmdletBinding()] [OutputType([psobject])] param( [AllowNull()]$Data, [hashtable]$Parameters = @{}, [AllowNull()]$Situation ) $scans = @(Get-NetworkProtocolScan -Parameters $Parameters) $broken = @($scans | Where-Object { $_.Problem }) if ($broken.Count -eq $scans.Count) { # Nothing was asked for, so nothing was read: that is not a quiet network. New-Finding -Category Gutcheck -Check (Get-Text 'Check.NetworkProtocol.Definition') -Severity INFO ` -Value (Get-Text 'Value.NetworkProtocol.NoScans') ` -Hint (Get-Text 'Hint.NetworkProtocol.FixDefinition') } elseif ($broken.Count) { New-Finding -Category Gutcheck -Check (Get-Text 'Check.NetworkProtocol.Definition') -Severity INFO ` -Value ((Get-Text 'Value.NetworkProtocol.BrokenScans') -f $broken.Count, $scans.Count) ` -Hint (Get-Text 'Hint.NetworkProtocol.FixDefinition') } # One Finding per topic, in the order the Definition first names each. $good = @($scans | Where-Object { -not $_.Problem }) $topics = New-Object 'System.Collections.Generic.List[string]' foreach ($scan in $good) { if (-not $topics.Contains($scan.Topic)) { $topics.Add($scan.Topic) } } foreach ($topic in $topics) { New-NetworkProtocolTopicFinding -Data $Data -Parameters $Parameters -Situation $Situation ` -Topic $topic -Scan @($good | Where-Object { $_.Topic -eq $topic }) } New-NetworkProtocolClockFinding -Data $Data -Parameters $Parameters New-NetworkProtocolInternetFinding -Data $Data New-UnreadableLogFinding -Data $Data -Parameters $Parameters } function ConvertTo-NetworkProtocolSection { [CmdletBinding()] [OutputType([psobject])] param([AllowNull()]$Data) $rows = @((Get-DataCollection $Data 'Events') | Sort-Object { Get-DataProperty $_ 'Time' } -Descending) $count = Get-Text 'Column.NetworkDisruption.Count' $topic = Get-Text 'Column.NetworkProtocol.Topic' $kind = Get-Text 'Column.NetworkDisruption.Kind' $subject = Get-Text 'Column.NetworkProtocol.Subject' $cause = Get-Text 'Column.NetworkProtocol.Cause' $first = Get-Text 'Column.NetworkDisruption.First' $last = Get-Text 'Column.NetworkDisruption.Last' $time = Get-Text 'Column.NetworkDisruption.Time' $provider = Get-Text 'Column.NetworkDisruption.Provider' $id = Get-Text 'Column.NetworkDisruption.Id' $message = Get-Text 'Column.NetworkDisruption.Message' $hidden = Get-Text 'Value.NetworkProtocol.SubjectWithheld' $named = { param($Row) if (Get-DataProperty $Row 'Withheld') { return $hidden } "$(Get-DataProperty $Row 'Subject')" } New-Section -Title (Get-Text 'Title.NetworkProtocol.Summary') -Help (Get-Text 'Help.NetworkProtocol.Summary') -Row @( $rows | Group-Object { '{0}|{1}|{2}|{3}' -f (Get-DataProperty $_ 'Topic'), (Get-DataProperty $_ 'Kind'), (& $named $_), (Get-DataProperty $_ 'Cause') } | Sort-Object @{ Expression = 'Count'; Descending = $true }, Name | ForEach-Object { $row = [ordered]@{} $row[$count] = $_.Count $row[$topic] = Get-NetworkProtocolTopicText -Topic "$(Get-DataProperty $_.Group[0] 'Topic')" -Part Name $row[$kind] = Get-NetworkProtocolKindText -Kind "$(Get-DataProperty $_.Group[0] 'Kind')" $row[$subject] = & $named $_.Group[0] $row[$cause] = "$(Get-DataProperty $_.Group[0] 'Cause')" $row[$first] = Get-DataProperty $_.Group[-1] 'Time' $row[$last] = Get-DataProperty $_.Group[0] 'Time' [pscustomobject]$row } ) New-Section -Title (Get-Text 'Title.NetworkProtocol.Events') -Help (Get-Text 'Help.NetworkProtocol.Events') -Row @( $rows | Select-Object -First $script:NetworkProtocolSectionRows | ForEach-Object { $row = [ordered]@{} $row[$time] = Get-DataProperty $_ 'Time' $row[$topic] = Get-NetworkProtocolTopicText -Topic "$(Get-DataProperty $_ 'Topic')" -Part Name $row[$kind] = Get-NetworkProtocolKindText -Kind "$(Get-DataProperty $_ 'Kind')" $row[$subject] = & $named $_ $row[$cause] = "$(Get-DataProperty $_ 'Cause')" $row[$provider] = Get-DataProperty $_ 'Provider' $row[$id] = Get-DataProperty $_ 'Id' $row[$message] = Get-DataProperty $_ 'Message' [pscustomobject]$row } ) } function ConvertTo-NetworkProtocolEvent { <# .SYNOPSIS The events this Check gathered, for the Report's events.csv. .DESCRIPTION A row whose message was not kept says what it is through its kind, what it was about and its cause: a line without any text would be a line nobody can read. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()]$Data) foreach ($row in (Get-DataCollection $Data 'Events')) { $message = "$(Get-DataProperty $row 'Message')" if (-not $message) { $message = @(@( (Get-NetworkProtocolKindText -Kind "$(Get-DataProperty $row 'Kind')") "$(Get-DataProperty $row 'Subject')" "$(Get-DataProperty $row 'Cause')") | Where-Object { $_ }) -join ' | ' } [pscustomobject]@{ Time = Get-DataProperty $row 'Time' Category = $script:NetworkProtocolEventTag Provider = Get-DataProperty $row 'Provider' Id = Get-DataProperty $row 'Id' Message = $message } } } |