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, # ntstatus, 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 # "QuietKinds": [ "domain-kerberos-fallback" ] } } known, and without # consequence: shown, never # a warning # # 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 } # Dns { Domain, SearchList, Adapters = { Alias, Suffix, Servers }, # Probes = { Server, Name, Type, State, Milliseconds, Code } }: what the # machine is set to ask, and what each of those servers answered when # asked by itself # Internet { Networks = { Alias, Connectivity }, ActiveProbeOff } # MachinePassword { AgeDays, MaximumAgeDays, ChangeDisabled }: how old the password of # the computer account is, as the domain has it; AgeDays $null where the # domain was not asked or did not say $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)) } 'ntstatus' { # A status of Windows is written in hexadecimal wherever it is looked up; the # event carries it as a number, 3221225566 for 0xC000005E. $number = ConvertTo-CodeNumber -Code $raw if ($null -eq $number) { return $raw } $code = '0x{0:X8}' -f ($number -band 0xFFFFFFFFL) return (Format-CodeWithMeaning -Code $code -Meaning (Get-NtStatusText -Code $code)) } '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 ConvertFrom-TimeServiceStatus { <# .SYNOPSIS What "w32tm /query /status" says, read by the place of each line: the leap indicator, the stratum, the last successful synchronisation and the source. Pure. .DESCRIPTION By place and not by label: the labels are in the language of Windows, and the order is not. Nine lines, each "label: value" - leap indicator, stratum, precision, root delay, root dispersion, reference id, last successful sync time, source, poll interval. Fewer than eight is an error message or another Windows, and nothing is read out of it. A time that does not read as one - "nicht angegeben" on a machine that never synchronised - is no time. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyCollection()][string[]]$Line, [cultureinfo]$Culture = [cultureinfo]::CurrentCulture) $lines = @($Line | ForEach-Object { "$_".Trim() } | Where-Object { $_ }) if ($lines.Count -lt 8) { return $null } $value = { param([int]$Index) $cut = $lines[$Index].IndexOf(':') if ($cut -lt 0) { '' } else { $lines[$Index].Substring($cut + 1).Trim() } } $leap = $null if ((& $value 0) -match '^(\d+)') { $leap = [int]$Matches[1] } $stratum = $null if ((& $value 1) -match '^(\d+)') { $stratum = [int]$Matches[1] } $lastSync = $null $parsed = [datetime]::MinValue if ([datetime]::TryParse((& $value 6), $Culture, [Globalization.DateTimeStyles]::None, [ref]$parsed)) { $lastSync = $parsed } [pscustomobject]@{ Leap = $leap; Stratum = $stratum; LastSync = $lastSync; Source = (& $value 7) } } function Get-NetworkProtocolTimeService { <# .SYNOPSIS Asks the time service of Windows how it stands: w32tm /query /status. Changes nothing - it is not told to synchronise. Never throws. .DESCRIPTION The answer comes in the code page of the console, which is not the one PowerShell reads a program's output in: read as that, or a label with an umlaut arrives as something else. #> [CmdletBinding()] [OutputType([psobject])] param([int]$TimeoutSeconds = 15) $lines = @() $exit = $null try { $start = [Diagnostics.ProcessStartInfo]::new((Join-Path $env:SystemRoot 'System32\w32tm.exe'), '/query /status') $start.UseShellExecute = $false $start.RedirectStandardOutput = $true $start.CreateNoWindow = $true try { $start.StandardOutputEncoding = [Text.Encoding]::GetEncoding([cultureinfo]::CurrentCulture.TextInfo.OEMCodePage) } catch { } $process = [Diagnostics.Process]::Start($start) $reading = $process.StandardOutput.ReadToEndAsync() if ($process.WaitForExit($TimeoutSeconds * 1000)) { $exit = $process.ExitCode $lines = @("$($reading.Result)" -split "`r?`n" | ForEach-Object { $_.TrimEnd() } | Where-Object { $_.Trim() }) } else { try { $process.Kill() } catch { } } } catch { } $status = $null if ($exit -eq 0) { $status = ConvertFrom-TimeServiceStatus -Line $lines } [pscustomobject]@{ Read = ($null -ne $status) Lines = $lines Leap = Get-DataProperty $status 'Leap' Stratum = Get-DataProperty $status 'Stratum' LastSync = Get-DataProperty $status 'LastSync' Source = "$(Get-DataProperty $status 'Source')" # What "how long ago" is counted from, so that the Judge never asks the clock. GatheredAt = Get-Date } } function New-NetworkProtocolTimeServiceFinding { <# .SYNOPSIS How the time service of Windows stands: when it last synchronised, and with what. Nothing where it was not asked. .DESCRIPTION A warning where it says of itself that it is not synchronised, where it never did, and where the last time is longer ago than a machine outside a domain goes between two - a week - with a day to spare. That it could not be read is said, and is no warning. #> [CmdletBinding()] param([AllowNull()]$Data, [hashtable]$Parameters) $service = Get-DataProperty $Data 'TimeService' if ($null -eq $service) { return } $check = Get-Text 'Check.NetworkProtocol.TimeService' $table = @(New-Reference -Section 'Title.NetworkProtocol.TimeService') if (-not [bool](Get-DataProperty $service 'Read')) { $said = @((Get-DataCollection $service 'Lines') | ForEach-Object { "$_".Trim() } | Where-Object { $_ }) -join ' ' $value = Get-Text 'Value.NetworkProtocol.TimeService.NotRead' if ($said) { $value = '{0} | {1}' -f $value, ((Get-Text 'Value.NetworkProtocol.TimeService.Said') -f $said) } return New-Finding -Category Network -Check $check -Severity INFO -Value $value ` -Meaning (Get-Text 'Meaning.NetworkProtocol.TimeService.NotRead') -Hint (Get-Text 'Hint.NetworkProtocol.TimeService.NotRead') } $source = "$(Get-DataProperty $service 'Source')".Trim() $lastSync = ConvertTo-DataTime (Get-DataProperty $service 'LastSync') $gathered = ConvertTo-DataTime (Get-DataProperty $service 'GatheredAt') $leap = ConvertTo-Number (Get-DataProperty $service 'Leap') $stratum = ConvertTo-Number (Get-DataProperty $service 'Stratum') $warnDays = [double](Get-Parameter $Parameters 'TimeSyncWarnDays' 8) $value = @() $severity = 'OK' if ($null -eq $lastSync) { $severity = 'WARN' $value += Get-Text 'Value.NetworkProtocol.TimeService.Never' } else { $ago = '' if ($null -ne $gathered) { $span = $gathered - $lastSync $ago = $(if ($span.TotalHours -lt 48) { (Get-Text 'Value.NetworkProtocol.TimeService.HoursAgo') -f [math]::Max(0, $span.TotalHours) } else { (Get-Text 'Value.NetworkProtocol.TimeService.DaysAgo') -f $span.TotalDays }) if ($span.TotalDays -gt $warnDays) { $severity = 'WARN' } } $value += (Get-Text 'Value.NetworkProtocol.TimeService.LastSync') -f $lastSync, $ago } # 3 is how the service says of itself that its clock is not synchronised. if ($leap -eq 3) { $severity = 'WARN' $value += Get-Text 'Value.NetworkProtocol.TimeService.Unsynchronised' } if ($source) { $value += (Get-Text 'Value.NetworkProtocol.TimeService.Source') -f $source } if ($null -ne $stratum) { $value += (Get-Text 'Value.NetworkProtocol.TimeService.Stratum') -f $stratum } if ($severity -eq 'OK') { return New-Finding -Category Network -Check $check -Severity OK -Value ($value -join ' | ') -Reference $table } New-Finding -Category Network -Check $check -Severity WARN -Value ($value -join ' | ') ` -Meaning (Get-Text 'Meaning.NetworkProtocol.TimeService.Stale') -Hint (Get-Text 'Hint.NetworkProtocol.TimeService.Stale') -Reference $table } function New-NetworkProtocolTimeServiceSection { <# .SYNOPSIS What the time service answered, line for line as Windows words it. Nothing where it was not asked or said nothing. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()]$Data) $lines = @((Get-DataCollection (Get-DataProperty $Data 'TimeService') 'Lines') | ForEach-Object { "$_".Trim() } | Where-Object { $_ }) if (-not $lines.Count) { return } $name = Get-Text 'Column.NetworkProtocol.Dns.Setting' $value = Get-Text 'Column.NetworkProtocol.Dns.Value' New-Section -Title (Get-Text 'Title.NetworkProtocol.TimeService') -Help (Get-Text 'Help.NetworkProtocol.TimeService') -Row @( foreach ($line in $lines) { $cut = $line.IndexOf(':') $row = [ordered]@{} $row[$name] = $(if ($cut -gt 0) { $line.Substring(0, $cut).Trim() } else { '' }) $row[$value] = $(if ($cut -gt 0) { $line.Substring($cut + 1).Trim() } else { $line }) [pscustomobject]$row } ) } 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 ConvertTo-NetworkProtocolDnsState { <# .SYNOPSIS What a DNS server did with a question, out of the error number the question ended with. Pure. .DESCRIPTION Answered it answered with records NameUnknown it answered that there is no such name (9003): a server that does not know the domain it was asked about NoAnswer it did not answer in time (1460), or could not be reached Refused it answered that it will not (9005) Failed anything else; the number is kept The numbers are Windows' own for DNS (winerror.h, DNS_ERROR_RCODE_*). #> [CmdletBinding()] [OutputType([string])] param([AllowNull()]$Code, [bool]$Answered) if ($Answered) { return 'Answered' } $number = ConvertTo-Number $Code if ($null -eq $number) { return 'NoAnswer' } switch ([int]$number) { 9003 { return 'NameUnknown' } 9501 { return 'NameUnknown' } 9005 { return 'Refused' } 1460 { return 'NoAnswer' } 9002 { return 'Failed' } } 'Failed' } function Get-NetworkProtocolDns { <# .SYNOPSIS Which DNS servers this machine is set to ask, with which suffixes, and what each of those servers answers when asked by itself. Decides nothing; never throws. .DESCRIPTION Windows asks the first server and goes on to the next when that one is silent: with two servers and one of them dead, every name still resolves - after a wait, some of the time. Asked now through Windows, DNS is in order. So each server is asked alone. What it is asked for is what it has to know: on a machine of a domain, the record the domain's controllers are found by (_ldap._tcp.dc._msdcs.<domain>) - a server that answers "no such name" to that is not one of the domain's, and the router or a public resolver entered beside the domain's own is the classic way to a logon that sometimes takes a minute. Elsewhere, the name a Check Definition gives. Only the adapters that are up, and no more than a handful of servers. The addresses Windows enters by itself where none is given (fec0:0:0:ffff::) are left out: nobody answers at them. #> [CmdletBinding()] [OutputType([psobject])] param([string]$ProbeName = 'www.msftconnecttest.com', [int]$Maximum = 8) $domain = '' try { $system = Get-CimInstance Win32_ComputerSystem -ErrorAction Stop if ($system.PartOfDomain) { $domain = "$($system.Domain)".Trim() } } catch { } $search = @() try { $search = @((Get-DnsClientGlobalSetting -ErrorAction Stop).SuffixSearchList | Where-Object { $_ } | ForEach-Object { "$_" }) } catch { } $adapters = @() try { $up = @(Get-NetAdapter -ErrorAction Stop | Where-Object { "$($_.Status)" -eq 'Up' } | ForEach-Object { "$($_.Name)" }) $suffix = @{} foreach ($client in @(Get-DnsClient -ErrorAction SilentlyContinue)) { $suffix["$($client.InterfaceAlias)"] = "$($client.ConnectionSpecificSuffix)" } $adapters = @(Get-DnsClientServerAddress -ErrorAction Stop | Where-Object { $up -contains "$($_.InterfaceAlias)" } | Group-Object InterfaceAlias | ForEach-Object { $servers = @($_.Group | ForEach-Object { $_.ServerAddresses } | Where-Object { $_ -and "$_" -notmatch '^(?i)fec0:0:0:ffff:' } | ForEach-Object { "$_" } | Select-Object -Unique) if ($servers.Count) { [pscustomobject]@{ Alias = "$($_.Name)"; Suffix = "$($suffix["$($_.Name)"])"; Servers = $servers } } }) } catch { $adapters = @() } $name = $ProbeName $type = 'A' if ($domain) { $name = '_ldap._tcp.dc._msdcs.{0}' -f $domain; $type = 'SRV' } $probes = @() if (Get-Command -Name Resolve-DnsName -ErrorAction SilentlyContinue) { $probes = @($adapters | ForEach-Object { $_.Servers } | Select-Object -Unique | Select-Object -First $Maximum | ForEach-Object { $server = "$_" $code = $null $answered = $false $watch = [System.Diagnostics.Stopwatch]::StartNew() try { $found = @(Resolve-DnsName -Name $name -Type $type -Server $server -DnsOnly -QuickTimeout -ErrorAction Stop) $answered = [bool]$found.Count } catch { $exception = $_.Exception while ($exception -and $null -eq $code) { if ($exception -is [System.ComponentModel.Win32Exception]) { $code = $exception.NativeErrorCode } $exception = $exception.InnerException } } $watch.Stop() [pscustomobject]@{ Server = $server Name = $name Type = $type State = ConvertTo-NetworkProtocolDnsState -Code $code -Answered $answered Milliseconds = [int]$watch.ElapsedMilliseconds Code = $code } }) } [pscustomobject]@{ Domain = $domain; SearchList = $search; Adapters = $adapters; Probes = $probes } } function Get-NetworkProtocolDnsStateText { <# .SYNOPSIS What a DNS server did with the question, in the Report's words, with the number and its meaning where it failed. Pure. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()]$Probe) switch ("$(Get-DataProperty $Probe 'State')") { 'Answered' { return ((Get-Text 'Value.NetworkProtocol.Dns.Answered') -f (Get-DataProperty $Probe 'Milliseconds')) } 'NameUnknown' { return (Get-Text 'Value.NetworkProtocol.Dns.NameUnknown') } 'NoAnswer' { return (Get-Text 'Value.NetworkProtocol.Dns.NoAnswer') } 'Refused' { return (Get-Text 'Value.NetworkProtocol.Dns.Refused') } 'Failed' { $code = "$(Get-DataProperty $Probe 'Code')" return ((Get-Text 'Value.NetworkProtocol.Dns.Failed') -f (Format-CodeWithMeaning -Code $code -Meaning (Get-Win32ErrorText -Code $code))) } } Get-Text 'Value.NetworkProtocol.Dns.NotAsked' } function New-NetworkProtocolDnsFinding { <# .SYNOPSIS What the DNS servers this machine is set to ask answer, each by itself. Nothing where that was not gathered or none is entered. .DESCRIPTION A server that is silent is a warning, and a failure where all are. A server that does not know the domain is a warning where it stands beside one that does on the same adapter - that is the mistake - and a note where it is all an adapter has: the server of a VPN or of a second network knows its own names and not these. #> [CmdletBinding()] param([AllowNull()]$Data) $dns = Get-DataProperty $Data 'Dns' $probes = @((Get-DataCollection $dns 'Probes') | ForEach-Object { $_ }) if (-not $probes.Count) { return } $state = @{} foreach ($probe in $probes) { $state["$(Get-DataProperty $probe 'Server')"] = "$(Get-DataProperty $probe 'State')" } $silent = @($probes | Where-Object { "$(Get-DataProperty $_ 'State')" -in 'NoAnswer', 'Refused', 'Failed' }) $unknown = @($probes | Where-Object { "$(Get-DataProperty $_ 'State')" -eq 'NameUnknown' }) $good = @($probes | Where-Object { "$(Get-DataProperty $_ 'State')" -eq 'Answered' }) # Beside a server that knows the domain, on one adapter: the mistake, not a second network. $mixed = @(foreach ($adapter in (Get-DataCollection $dns 'Adapters')) { $servers = @((Get-DataCollection $adapter 'Servers') | ForEach-Object { "$_" }) if (@($servers | Where-Object { $state[$_] -eq 'Answered' }).Count) { $servers | Where-Object { $state[$_] -eq 'NameUnknown' } } }) | Select-Object -Unique $severity = 'OK' if ($unknown.Count) { $severity = 'INFO' } if ($silent.Count -or @($mixed).Count) { $severity = 'WARN' } if (-not $good.Count) { $severity = 'FAIL' } # No server knows the name and none is silent: nothing wrong with DNS, the name is # one this network does not have. if (-not $good.Count -and -not $silent.Count) { $severity = 'INFO' } $value = @((Get-Text 'Value.NetworkProtocol.Dns.Summary') -f $good.Count, $probes.Count, "$(Get-DataProperty $probes[0] 'Name')") $value += (Get-Text 'Value.NetworkProtocol.Dns.Servers') -f (@($probes | ForEach-Object { '{0} {1}' -f "$(Get-DataProperty $_ 'Server')", (Get-NetworkProtocolDnsStateText -Probe $_) }) -join '; ') $check = Get-Text 'Check.NetworkProtocol.DnsServers' if ($severity -eq 'OK') { return New-Finding -Category Network -Check $check -Severity OK -Value ($value -join ' | ') } $meaning = @() $hint = @() if ($silent.Count) { $meaning += Get-Text 'Meaning.NetworkProtocol.Dns.Silent' $hint += Get-Text 'Hint.NetworkProtocol.Dns.Silent' } if ($unknown.Count) { if (@($mixed).Count) { $meaning += Get-Text 'Meaning.NetworkProtocol.Dns.Foreign'; $hint += Get-Text 'Hint.NetworkProtocol.Dns.Foreign' } else { $meaning += Get-Text 'Meaning.NetworkProtocol.Dns.OtherNetwork'; $hint += Get-Text 'Hint.NetworkProtocol.Dns.OtherNetwork' } } New-Finding -Category Network -Check $check -Severity $severity -Value ($value -join ' | ') ` -Meaning ($meaning -join ' | ') -Hint ($hint -join ' | ') ` -Reference @(New-Reference -Section 'Title.NetworkProtocol.DnsServers') } function New-NetworkProtocolDnsSection { <# .SYNOPSIS DNS in one place: the servers the machine asks and what each answered, how names are completed, and every event of the period that is about DNS - also where a table further down shows the same. Pure. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()]$Data) $dns = Get-DataProperty $Data 'Dns' $probes = @((Get-DataCollection $dns 'Probes') | ForEach-Object { $_ }) $adapterColumn = Get-Text 'Column.NetworkProtocol.Dns.Adapter' $serverColumn = Get-Text 'Column.NetworkProtocol.Dns.Server' $answerColumn = Get-Text 'Column.NetworkProtocol.Dns.Answer' $askedColumn = Get-Text 'Column.NetworkProtocol.Dns.Asked' New-Section -Title (Get-Text 'Title.NetworkProtocol.DnsServers') -Help (Get-Text 'Help.NetworkProtocol.DnsServers') -Row @( foreach ($adapter in (Get-DataCollection $dns 'Adapters')) { foreach ($server in @((Get-DataCollection $adapter 'Servers') | ForEach-Object { "$_" })) { $probe = @($probes | Where-Object { "$(Get-DataProperty $_ 'Server')" -eq $server })[0] $row = [ordered]@{} $row[$adapterColumn] = "$(Get-DataProperty $adapter 'Alias')" $row[$serverColumn] = $server $row[$answerColumn] = Get-NetworkProtocolDnsStateText -Probe $probe $row[$askedColumn] = "$(Get-DataProperty $probe 'Name')" [pscustomobject]$row } } ) $name = Get-Text 'Column.NetworkProtocol.Dns.Setting' $value = Get-Text 'Column.NetworkProtocol.Dns.Value' $none = Get-Text 'Value.NetworkProtocol.Dns.None' $setting = { param([string]$Label, [string]$Text) $row = [ordered]@{} $row[$name] = $Label $row[$value] = $(if ($Text) { $Text } else { $none }) [pscustomobject]$row } if ($null -ne $dns) { New-Section -Title (Get-Text 'Title.NetworkProtocol.DnsSettings') -Help (Get-Text 'Help.NetworkProtocol.DnsSettings') -Row @( & $setting (Get-Text 'Value.NetworkProtocol.Dns.Setting.Domain') "$(Get-DataProperty $dns 'Domain')" & $setting (Get-Text 'Value.NetworkProtocol.Dns.Setting.SearchList') (@((Get-DataCollection $dns 'SearchList') | ForEach-Object { "$_" }) -join ', ') foreach ($adapter in (Get-DataCollection $dns 'Adapters')) { & $setting ((Get-Text 'Value.NetworkProtocol.Dns.Setting.Suffix') -f "$(Get-DataProperty $adapter 'Alias')") "$(Get-DataProperty $adapter 'Suffix')" } ) } # What Windows logged that is about DNS, whichever topic it stands under: a name # nobody answered for, a time source DNS did not find, Windows' own DNS probe. $events = @((Get-DataCollection $Data 'Events') | Where-Object { "$(Get-DataProperty $_ 'Topic')" -eq 'dns' -or "$(Get-DataProperty $_ 'Cause')" -match 'DnsProbe' -or ("$(Get-DataProperty $_ 'Kind')" -eq 'time-no-source' -and "$(Get-DataProperty $_ 'Subject')") } | Sort-Object { Get-DataProperty $_ 'Time' } -Descending) New-Section -Title (Get-Text 'Title.NetworkProtocol.DnsEvents') -Help (Get-Text 'Help.NetworkProtocol.DnsEvents') -Row @( $events | Select-Object -First $script:NetworkProtocolSectionRows | ForEach-Object { $row = [ordered]@{} $row[(Get-Text 'Column.NetworkDisruption.Time')] = Get-DataProperty $_ 'Time' $row[(Get-Text 'Column.NetworkDisruption.Kind')] = Get-NetworkProtocolKindText -Kind "$(Get-DataProperty $_ 'Kind')" $row[(Get-Text 'Column.NetworkProtocol.Subject')] = "$(Get-DataProperty $_ 'Subject')" $row[(Get-Text 'Column.NetworkProtocol.Cause')] = "$(Get-DataProperty $_ 'Cause')" $row[(Get-Text 'Column.NetworkDisruption.Message')] = Get-DataProperty $_ 'Message' [pscustomobject]$row } ) } function Get-NetworkProtocolMachinePassword { <# .SYNOPSIS How old the password of this computer's account in the domain is, and how the machine is set to renew it. Reads the directory, and changes nothing. Never throws. .DESCRIPTION A machine renews the password of its account every thirty days by itself. Where that stops, nothing fails at once - and then, one day, the domain no longer knows the machine. Reported for Windows 11 24H2 beside the secure channel that comes about at the second attempt; the age of the password is what tells a machine that only logs the attempt from one that has the fault. Asked only where a DNS server just answered for the domain: without a domain controller in reach the question waits for one, for longer than a Run should. #> [CmdletBinding()] [OutputType([psobject])] param([bool]$DomainReachable) $maximum = 30 $disabled = $false try { $settings = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\Netlogon\Parameters' -ErrorAction Stop if ($null -ne $settings.MaximumPasswordAge) { $maximum = [int]$settings.MaximumPasswordAge } $disabled = ("$($settings.DisablePasswordChange)" -eq '1') } catch { } $age = $null if ($DomainReachable) { try { $searcher = New-Object System.DirectoryServices.DirectorySearcher $searcher.Filter = '(&(objectCategory=computer)(sAMAccountName={0}$))' -f $env:COMPUTERNAME $searcher.ClientTimeout = [timespan]::FromSeconds(8) $null = $searcher.PropertiesToLoad.Add('pwdLastSet') $found = $searcher.FindOne() if ($found -and $found.Properties['pwdlastset'].Count) { $set = [datetime]::FromFileTime([long]"$($found.Properties['pwdlastset'][0])") $age = [int][math]::Floor(((Get-Date) - $set).TotalDays) } } catch { $age = $null } } [pscustomobject]@{ AgeDays = $age; MaximumAgeDays = $maximum; ChangeDisabled = $disabled } } function New-NetworkProtocolMachinePasswordFinding { <# .SYNOPSIS Whether the machine still renews the password of its account. Nothing where the domain was not asked. .DESCRIPTION In order up to the age the machine is set to renew at, and for as long again: a notebook that was away renews when it is back. A warning beyond that - the domain was reachable just now, and the password is still old. Renewal switched off is a note: somebody did that. #> [CmdletBinding()] param([AllowNull()]$Data, [hashtable]$Parameters) $password = Get-DataProperty $Data 'MachinePassword' $age = ConvertTo-Number (Get-DataProperty $password 'AgeDays') if ($null -eq $age) { return } $maximum = ConvertTo-Number (Get-DataProperty $password 'MaximumAgeDays') if ($null -eq $maximum -or $maximum -le 0) { $maximum = 30 } $warn = ConvertTo-Number (Get-Parameter $Parameters 'MachinePasswordWarnDays' ($maximum * 2)) if ($null -eq $warn) { $warn = $maximum * 2 } $check = Get-Text 'Check.NetworkProtocol.MachinePassword' $value = (Get-Text 'Value.NetworkProtocol.MachinePassword') -f [int]$age, [int]$maximum if ([bool](Get-DataProperty $password 'ChangeDisabled')) { return New-Finding -Category Domain -Check $check -Severity INFO ` -Value ('{0} | {1}' -f $value, (Get-Text 'Value.NetworkProtocol.MachinePassword.Disabled')) ` -Meaning (Get-Text 'Meaning.NetworkProtocol.MachinePassword.Disabled') ` -Hint (Get-Text 'Hint.NetworkProtocol.MachinePassword.Disabled') } if ($age -le $warn) { return New-Finding -Category Domain -Check $check -Severity OK -Value $value } New-Finding -Category Domain -Check $check -Severity WARN -Value $value ` -Meaning (Get-Text 'Meaning.NetworkProtocol.MachinePassword.Old') ` -Hint (Get-Text 'Hint.NetworkProtocol.MachinePassword.Old') } 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)) } } } # When the machine started or woke up, where the Check Definition names the events # that say so. $null where it names none: then nothing is known of it, which is not # the same as a machine that never started. $starts = $null $startScans = @(@(Get-Parameter $Parameters 'StartEvents' @()) | Where-Object { $null -ne $_ } | ForEach-Object { ConvertTo-NetworkDisruptionScan -Entry $_ } | Where-Object { -not $_.Problem }) if ($startScans.Count) { $starts = @(foreach ($query in @(Select-NetworkDisruptionQuery -Query @(Get-NetworkDisruptionQuery -Scan $startScans) ` -Registration (Get-NetworkDisruptionRegistration))) { Get-StabilityEvent -Log $query.Log -Provider $query.Provider -Id $query.Id -Since $since -Unreadable $unreadable | Where-Object { Select-NetworkDisruptionScan -LogEntry $_ -Log $query.Log -Scan $startScans } | ForEach-Object { $_.TimeCreated } }) } # 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 } }) $dns = Get-NetworkProtocolDns -ProbeName "$(Get-Parameter $Parameters 'DnsProbeName' 'www.msftconnecttest.com')" # A DNS server that knows the domain answered a moment ago: then a domain controller # can be asked without waiting for one that is not there. $reachable = [bool]("$($dns.Domain)" -and @($dns.Probes | Where-Object { $_.State -eq 'Answered' }).Count) [pscustomobject]@{ PSTypeName = 'Gutcheck.Data.NetworkProtocol' Days = $days Since = $since Events = @($rows | Sort-Object Time -Descending) UnreadableLogs = @($unreadableLogs | Sort-Object) ScannedLogs = [string[]]@($scanned) LogCoverage = $coverage Starts = $starts Clock = Get-NetworkProtocolClock TimeService = Get-NetworkProtocolTimeService Dns = $dns MachinePassword = Get-NetworkProtocolMachinePassword -DomainReachable $reachable 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') } 'domain-channel-retried' { return (Get-Text 'Value.NetworkProtocol.Kind.DomainChannelRetried') } 'domain-kerberos-fallback' { return (Get-Text 'Value.NetworkProtocol.Kind.DomainKerberosFallback') } 'domain-channel-failed' { return (Get-Text 'Value.NetworkProtocol.Kind.DomainChannelFailed') } '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 { $_ }) QuietKinds = [string[]]@(@(Get-DataProperty $entry 'QuietKinds') | ForEach-Object { "$_" } | Where-Object { $_ }) # What Windows logs when it starts before its network is up. Where a Definition # does not say, what is expected away from the network: it is the same state. StartKinds = [string[]]@(@(Get-DataProperty $entry $(if ($null -ne (Get-DataProperty $entry 'StartKinds')) { 'StartKinds' } else { 'AwayKinds' })) | ForEach-Object { "$_" } | Where-Object { $_ }) } } function Get-NetworkProtocolAtStart { <# .SYNOPSIS The events of the kinds given that were logged in the first minutes after the machine started or woke up. Pure. .DESCRIPTION Windows asks for its domain controller and its time source before the network is up, and logs that it found none - at every start. Seen on a desktop: 81 such events in a month, every one within a minute of one of its 27 starts, and a warning about a domain that was in order. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyCollection()]$Row, [AllowNull()][AllowEmptyCollection()]$Start, [AllowNull()][AllowEmptyCollection()][string[]]$Kind, [double]$Seconds = 180) $times = @($Start | ForEach-Object { ConvertTo-DataTime $_ } | Where-Object { $null -ne $_ }) if (-not $times.Count -or -not @($Kind).Count) { return } foreach ($one in @($Row | Where-Object { $_ })) { if (@($Kind) -notcontains "$(Get-DataProperty $one 'Kind')") { continue } $at = ConvertTo-DataTime (Get-DataProperty $one 'Time') if ($null -eq $at) { continue } if (@($times | Where-Object { $at -ge $_ -and ($at - $_).TotalSeconds -le $Seconds }).Count) { $one } } } function Get-NetworkProtocolDomainText { <# .SYNOPSIS What the events of the domain's topic mean and what to do about them, by the kinds that are among them. Pure. .DESCRIPTION All of it at once was six statements about four things, of which a machine had one: a broken trust explained beside 366 harmless retries and one controller that did not answer. So: how much of it is the fallback of Kerberos, which is without consequence; then what each kind that is there means; the fallback last. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyCollection()]$Row, [AllowNull()][AllowEmptyCollection()]$AtStart) $all = @($Row | Where-Object { $_ }) $starting = @($AtStart | Where-Object { $_ }) $kinds = @($all | ForEach-Object { "$(Get-DataProperty $_ 'Kind')" }) $fallback = @($kinds | Where-Object { $_ -in 'domain-channel-retried', 'domain-kerberos-fallback' }) # What is left to look at: neither the fallback nor what a start logs. $other = @($all | Where-Object { $starting -notcontains $_ } | ForEach-Object { "$(Get-DataProperty $_ 'Kind')" } | Where-Object { $_ -notin 'domain-channel-retried', 'domain-kerberos-fallback' }) $meaning = @() $hint = @() if ($other.Count) { if ($kinds.Count -gt $other.Count) { $meaning += (Get-Text 'Meaning.NetworkProtocol.Domain.Mostly') -f ($kinds.Count - $other.Count), $kinds.Count, $other.Count } $meaning += Get-Text 'Meaning.NetworkProtocol.Domain' if (@($other | Where-Object { $_ -in 'domain-no-controller', 'domain-controller-silent', 'domain-channel-failed' }).Count) { $meaning += Get-Text 'Meaning.NetworkProtocol.Domain.Reach' $hint += Get-Text 'Hint.NetworkProtocol.Domain.Reach' } if ($other -contains 'domain-trust-broken') { $meaning += Get-Text 'Meaning.NetworkProtocol.Domain.Trust' $hint += Get-Text 'Hint.NetworkProtocol.Domain.Trust' } if (@($other | Where-Object { $_ -in 'kerberos-ticket-refused', 'kerberos-clock' }).Count) { $meaning += Get-Text 'Meaning.NetworkProtocol.Domain.Kerberos' $hint += Get-Text 'Hint.NetworkProtocol.Domain.Kerberos' } if (@($other | Where-Object { $_ -in 'auth-failed', 'auth-no-protocol' }).Count) { $meaning += Get-Text 'Meaning.NetworkProtocol.Domain.Auth' $hint += Get-Text 'Hint.NetworkProtocol.Domain.Auth' } $hint += Get-Text 'Hint.NetworkProtocol.Domain' } if ($starting.Count) { $meaning += Get-Text 'Meaning.NetworkProtocol.AtStart' if (-not $other.Count -and -not $fallback.Count) { $hint += Get-Text 'Hint.NetworkProtocol.AtStart' } } if ($fallback.Count) { $meaning += Get-Text 'Meaning.NetworkProtocol.Domain.KerberosFallback' $hint += Get-Text 'Hint.NetworkProtocol.Domain.KerberosFallback' } [pscustomobject]@{ Meaning = $meaning; Hint = $hint } } function Get-NetworkProtocolTopicCategory { <# .SYNOPSIS The area of the Report a topic's Finding stands in: the domain's among the Findings about the domain, every other under the network. Pure. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyString()][string]$Topic) if ("$Topic" -eq 'domain') { return 'Domain' } 'Network' } 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 $_ }) $quiet = New-NetworkDisruptionQuietFinding -Data $Data -Check $check -Unread $unread -Log $logs if ($quiet) { $quiet.Category = Get-NetworkProtocolTopicCategory -Topic $Topic } return $quiet } $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')" }) # What is known and without consequence is shown and never counted: a first attempt # that fails by design, every few hours, is no reason to look at a domain controller. $quietRows = @($rows | Where-Object { $setting.QuietKinds -contains "$(Get-DataProperty $_ 'Kind')" }) # And what is logged while the machine starts, before its network is up. $startSeconds = ConvertTo-Number (Get-Parameter $Parameters 'StartSeconds' 180) if ($null -eq $startSeconds -or $startSeconds -le 0) { $startSeconds = 180 } $startRows = @(Get-NetworkProtocolAtStart -Row $rows -Start @((Get-DataCollection $Data 'Starts') | ForEach-Object { $_ }) ` -Kind $setting.StartKinds -Seconds $startSeconds) $counted = @($rows | Where-Object { $token = "$(Get-DataProperty $_ 'Kind')" -not ($setting.QuietKinds -contains $token -or ($away -and $setting.AwayKinds -contains $token) -or $startRows -contains $_) }).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) -and $startRows -notcontains $_ }).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 } if ($startRows.Count) { $value += (Get-Text 'Value.NetworkProtocol.AtStart') -f $startRows.Count } $meaning = @(Get-NetworkProtocolTopicText -Topic $Topic -Part Meaning) $hint = @(Get-NetworkProtocolTopicText -Topic $Topic -Part Hint) if ($away -and $awayRows.Count) { $meaning += Get-Text 'Meaning.NetworkProtocol.Away' } # The domain's topic is many kinds of event with nothing in common but the domain: # it says what belongs to the kinds that were found, and not all of it every time. if ($Topic -eq 'domain') { $said = Get-NetworkProtocolDomainText -Row $rows -AtStart $startRows $beside = @($meaning | Select-Object -Skip 1) $meaning = @($said.Meaning) + $beside $hint = @($said.Hint) } elseif ($startRows.Count) { $meaning += Get-Text 'Meaning.NetworkProtocol.AtStart' } New-Finding -Category (Get-NetworkProtocolTopicCategory -Topic $Topic) -Check $check -Severity $severity ` -Value ($value -join ' | ') ` -Meaning (@($meaning | Where-Object { $_ }) -join ' | ') ` -Hint (@($hint | Where-Object { $_ }) -join ' | ') ` -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-NetworkProtocolDnsFinding -Data $Data New-NetworkProtocolMachinePasswordFinding -Data $Data -Parameters $Parameters New-NetworkProtocolClockFinding -Data $Data -Parameters $Parameters New-NetworkProtocolTimeServiceFinding -Data $Data -Parameters $Parameters New-NetworkProtocolInternetFinding -Data $Data New-UnreadableLogFinding -Data $Data -Parameters $Parameters } function ConvertTo-NetworkProtocolSection { [CmdletBinding()] [OutputType([psobject])] param([AllowNull()]$Data) # DNS first, and in one place: it is what most of the rest depends on. New-NetworkProtocolDnsSection -Data $Data New-NetworkProtocolTimeServiceSection -Data $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 } } } |