Private/Kinds/NetworkDependencyReference.ps1
|
# What on this machine still points at a Server that does not answer: a part of the # NetworkDependency Kind, and no Kind. # # Windows logs that something asked for a Server whose name does not resolve - 339 times # in a month, on one machine, only in working hours and at no regular interval. That is # somebody working: every time the Explorer shows its quick access, a program opens its # list of recent files or a dialog shows a remembered place, Windows tries the Server # again and waits for it to fail. The Finding about failed connections said "look for # what still points at it", and a Technician had nowhere to start. Observed 2026-10-14: # no drive, no printer, no autostart entry and no scheduled task named that Server; what # did was in none of the places a Run looked in. # # So this looks, for the Servers Windows logged as failed and for no others, in the # places of the User of the Run where such a pointer lives: # # quick-access the Explorer's quick access: its pinned and its frequent folders # jump-list the lists of recent files that programs keep in the taskbar # recent the shortcuts Windows writes for every file that was opened # shortcut shortcuts on the desktop, in the links and in the start menu # place network places, and the shares the Explorer remembers visiting # office paths in the settings of Office: templates, default folders, recent files # typed addresses typed into the Explorer and into the Run dialog # drive drives that are to be reconnected at sign-in # printer printer connections of the User # # Kept is the Server, the place and the share - "\\Server\Share" - and never the path # below it or the name of a file: which documents somebody opened is what that person # did, and a Run reads the state of a machine (ADR-0006). The network is the exception # that ADR makes, and a share is the network. # # Only for the User of the Run: these places are in that User's profile and registry. # How large a file may be to be searched, and how many files of one place. $script:ReferenceMaxFileBytes = 4MB $script:ReferenceMaxFiles = 1500 # How many values of the registry are read under one key at most. $script:ReferenceMaxValues = 40000 # How many Servers are looked for: the ones most often asked for in vain. $script:ReferenceMaxServers = 8 function Get-ReferencePattern { <# .SYNOPSIS What a pointer at one of several Servers looks like in a text: "\\Server\Share", the Server with or without its domain, in any case. One pattern for all of them, so that a text is read once however many Servers are looked for. Pure. #> [CmdletBinding()] [OutputType([regex])] param([Parameter(Mandatory)][string[]]$Server) $names = @($Server | ForEach-Object { "$_".Trim() } | Where-Object { $_ } | ForEach-Object { [regex]::Escape($_) }) -join '|' # In a registry key and in a printer connection the separators are hashes or commas # instead of backslashes: "##Server#Share" and ",,Server,Printer". [regex]('(?i)(?:\\\\|,,|##)(?<server>' + $names + ')(?:\.[a-z0-9.-]+)?(?:\\|,|#)(?<share>[^\\,#\x00-\x1f"<>|?*:]{1,80})') } function Find-ReferenceShare { <# .SYNOPSIS The shares of the Servers looked for that a text points at: each as its Server, written as it was asked for, and "\\Server\Share", each once. Nothing where it points at none. Pure. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyString()][string]$Text, [Parameter(Mandatory)][string[]]$Server, [regex]$Pattern) if (-not $Text) { return } if (-not $Pattern) { $Pattern = Get-ReferencePattern -Server $Server } $names = @($Server | ForEach-Object { "$_".Trim() } | Where-Object { $_ }) $seen = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase) foreach ($match in $Pattern.Matches($Text)) { $share = $match.Groups['share'].Value.Trim() if (-not $share) { continue } $asked = @($names | Where-Object { $_ -eq $match.Groups['server'].Value })[0] if (-not $asked) { continue } $unc = '\\{0}\{1}' -f $asked, $share if ($seen.Add($unc)) { [pscustomobject]@{ Server = $asked; Share = $unc } } } } function Read-ReferenceFileText { <# .SYNOPSIS What a file holds as text, whatever kind of file it is: read as the two ways Windows writes a path into a shortcut or a list. Empty where it would not open. .DESCRIPTION A shortcut and a jump list are binary files that hold their paths as plain characters, one byte each or two. Read as both, and as two bytes from either start, because such a path begins wherever the file put it. #> [CmdletBinding()] [OutputType([string])] param([Parameter(Mandatory)][string]$Path) try { $stream = [IO.File]::Open($Path, [IO.FileMode]::Open, [IO.FileAccess]::Read, ([IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete)) try { $length = [int][math]::Min([long]$script:ReferenceMaxFileBytes, $stream.Length) $bytes = New-Object byte[] $length $read = $stream.Read($bytes, 0, $length) } finally { $stream.Dispose() } if ($read -lt 2) { return '' } $latin = [Text.Encoding]::GetEncoding(28591).GetString($bytes, 0, $read) $wide = [Text.Encoding]::Unicode.GetString($bytes, 0, $read - ($read % 2)) $odd = [Text.Encoding]::Unicode.GetString($bytes, 1, ($read - 1) - (($read - 1) % 2)) "$latin`n$wide`n$odd" } catch { '' } } function Get-ReferencePlace { <# .SYNOPSIS The places of the User of the Run in which a pointer at a Server lives: each as its token and either the files or the registry key to search. Asks only. #> [CmdletBinding()] [OutputType([psobject])] param() $roaming = [Environment]::GetFolderPath('ApplicationData') $recent = Join-Path $roaming 'Microsoft\Windows\Recent' $file = { param($Token, $Folder, $Filter, [switch]$Deep) if (-not (Test-Path -LiteralPath $Folder -PathType Container)) { return } $found = @(Get-ChildItem -LiteralPath $Folder -Filter $Filter -File -Force -Recurse:$Deep -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First $script:ReferenceMaxFiles | ForEach-Object { $_.FullName }) if ($found.Count) { [pscustomobject]@{ Token = $Token; Files = $found; Key = '' } } } # The Explorer's own two lists are known by their names; every other is a program's. $own = 'f01b4d95cf55d32a.automaticDestinations-ms', '5f7b5f1e01b83767.automaticDestinations-ms' $lists = Join-Path $recent 'AutomaticDestinations' if (Test-Path -LiteralPath $lists -PathType Container) { $all = @(Get-ChildItem -LiteralPath $lists -File -Force -ErrorAction SilentlyContinue) $quick = @($all | Where-Object { $own -contains $_.Name } | ForEach-Object { $_.FullName }) $other = @($all | Where-Object { $own -notcontains $_.Name } | Select-Object -First $script:ReferenceMaxFiles | ForEach-Object { $_.FullName }) if ($quick.Count) { [pscustomobject]@{ Token = 'quick-access'; Files = $quick; Key = '' } } if ($other.Count) { [pscustomobject]@{ Token = 'jump-list'; Files = $other; Key = '' } } } & $file 'jump-list' (Join-Path $recent 'CustomDestinations') '*' & $file 'recent' $recent '*.lnk' & $file 'shortcut' ([Environment]::GetFolderPath('DesktopDirectory')) '*.lnk' & $file 'shortcut' (Join-Path ([Environment]::GetFolderPath('UserProfile')) 'Links') '*.lnk' & $file 'shortcut' ([Environment]::GetFolderPath('StartMenu')) '*.lnk' -Deep & $file 'place' (Join-Path $roaming 'Microsoft\Windows\Network Shortcuts') '*' -Deep foreach ($pair in @( @('place', 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\MountPoints2'), @('office', 'HKCU:\Software\Microsoft\Office'), @('typed', 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\TypedPaths'), @('typed', 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\RunMRU'), @('drive', 'HKCU:\Network'), @('printer', 'HKCU:\Printers\Connections'))) { if (Test-Path -LiteralPath $pair[1]) { [pscustomobject]@{ Token = $pair[0]; Files = @(); Key = $pair[1] } } } } function Read-ReferenceRegistryText { <# .SYNOPSIS Everything under one registry key that could hold a path, as one text: the names of its keys and the names and texts of its values. Asks only. #> [CmdletBinding()] [OutputType([string])] param([Parameter(Mandatory)][string]$Key) $text = New-Object System.Text.StringBuilder $left = $script:ReferenceMaxValues $keys = @(Get-Item -LiteralPath $Key -ErrorAction SilentlyContinue) + @(Get-ChildItem -LiteralPath $Key -Recurse -ErrorAction SilentlyContinue) foreach ($one in $keys) { if ($left -le 0) { break } $null = $text.AppendLine($one.PSChildName) foreach ($name in $one.GetValueNames()) { if (--$left -le 0) { break } $value = $one.GetValue($name, $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) if ($value -is [string]) { $null = $text.Append($name).Append('=').AppendLine($value) } elseif ($value -is [string[]]) { $null = $text.Append($name).Append('=').AppendLine(($value -join "`n")) } elseif ($value -is [byte[]] -and $value.Length -le 65536) { # A list of recent places is kept as bytes that hold the path two bytes a letter. $null = $text.Append($name).Append('=').AppendLine([Text.Encoding]::Unicode.GetString($value, 0, $value.Length - ($value.Length % 2))) } else { $null = $text.AppendLine($name) } } } $text.ToString() } function Get-ReferenceFailedServer { <# .SYNOPSIS The Servers Windows logged as asked for in vain, the most often asked for first: from what the Check of the network's disruptions gathered. Pure. #> [CmdletBinding()] [OutputType([string[]])] param([AllowNull()]$NetworkDisruption, [string[]]$Kind = @('smb-connect-failed'), [int]$First = $script:ReferenceMaxServers) @((Get-DataCollection $NetworkDisruption 'Disruptions') | Where-Object { $_ -and $Kind -contains "$(Get-DataProperty $_ 'Kind')" } | ForEach-Object { "$(Get-DataProperty $_ 'Server')".Trim() } | Where-Object { $_ } | Group-Object | Sort-Object @{ Expression = 'Count'; Descending = $true }, Name | Select-Object -First $First | ForEach-Object { $_.Name }) } function Get-NetworkDependencyReference { <# .SYNOPSIS Where on this machine the Servers that were asked for in vain are still pointed at. Returns what was searched for, where, and what was found. Asks only. .DESCRIPTION Nothing is searched where no Server failed, or nobody read whether one did: the places are then not opened at all. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()]$NetworkDisruption) $servers = @(Get-ReferenceFailedServer -NetworkDisruption $NetworkDisruption) if (-not $servers.Count) { return [pscustomobject]@{ Searched = $false; Servers = @(); Places = @(); Found = @() } } # A place may be several folders - the shortcuts are the desktop's and the start # menu's - and is one place in what is said: counted and named together. $pattern = Get-ReferencePattern -Server $servers $places = New-Object 'System.Collections.Generic.List[string]' $rows = [ordered]@{} foreach ($place in @(Get-ReferencePlace)) { if (-not $places.Contains($place.Token)) { $places.Add($place.Token) } $texts = @() if ($place.Key) { $texts = @(Read-ReferenceRegistryText -Key $place.Key) } else { $texts = @($place.Files | ForEach-Object { Read-ReferenceFileText -Path $_ }) } foreach ($text in $texts) { $in = @(Find-ReferenceShare -Text $text -Server $servers -Pattern $pattern) foreach ($server in @($in | ForEach-Object { $_.Server } | Select-Object -Unique)) { $key = '{0}|{1}' -f $server, $place.Token if (-not $rows.Contains($key)) { $rows[$key] = [pscustomobject]@{ Server = $server; Place = $place.Token; Count = 0 Shares = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase) } } $rows[$key].Count++ foreach ($one in @($in | Where-Object { $_.Server -eq $server })) { $null = $rows[$key].Shares.Add($one.Share) } } } } # In the order the Servers were asked for, and within one in the order of the places. $found = @(foreach ($server in $servers) { foreach ($row in @($rows.Values | Where-Object { $_.Server -eq $server })) { [pscustomobject]@{ Server = $row.Server; Place = $row.Place; Count = $row.Count; Shares = @($row.Shares | Sort-Object) } } }) [pscustomobject]@{ Searched = $true; Servers = $servers; Places = @($places); Found = @($found) } } function Get-ReferencePlaceText { # A place as the Report names it. Pure. [CmdletBinding()] [OutputType([string])] param([AllowNull()][string]$Place) switch ("$Place") { 'quick-access' { return (Get-Text 'Value.Reference.Place.QuickAccess') } 'jump-list' { return (Get-Text 'Value.Reference.Place.JumpList') } 'recent' { return (Get-Text 'Value.Reference.Place.Recent') } 'shortcut' { return (Get-Text 'Value.Reference.Place.Shortcut') } 'place' { return (Get-Text 'Value.Reference.Place.Place') } 'office' { return (Get-Text 'Value.Reference.Place.Office') } 'typed' { return (Get-Text 'Value.Reference.Place.Typed') } 'drive' { return (Get-Text 'Value.Reference.Place.Drive') } 'printer' { return (Get-Text 'Value.Reference.Place.Printer') } } "$Place" } function Get-ReferencePlaceHint { # What to do about a pointer in a place. Pure. [CmdletBinding()] [OutputType([string])] param([AllowNull()][string]$Place) switch ("$Place") { 'quick-access' { return (Get-Text 'Hint.Reference.QuickAccess') } 'jump-list' { return (Get-Text 'Hint.Reference.Recent') } 'recent' { return (Get-Text 'Hint.Reference.Recent') } 'shortcut' { return (Get-Text 'Hint.Reference.Shortcut') } 'place' { return (Get-Text 'Hint.Reference.Place') } 'office' { return (Get-Text 'Hint.Reference.Office') } 'typed' { return (Get-Text 'Hint.Reference.Typed') } 'drive' { return (Get-Text 'Hint.Reference.Drive') } 'printer' { return (Get-Text 'Hint.Reference.Printer') } } '' } function New-NetworkDependencyReferenceFinding { <# .SYNOPSIS Where this machine still points at the Servers it asked for in vain, and what to do about each place. Nothing where nothing was searched. .DESCRIPTION A WARN where a pointer was found: it is the thing to remove, and the answer to the Finding about failed connections. Where none was found that is said too, with where the search went - so that the Technician knows what is left to look at, and does not look there again. #> [CmdletBinding()] param([AllowNull()]$Data) $search = Get-DataProperty $Data 'References' if (-not [bool](Get-DataProperty $search 'Searched')) { return } $servers = @((Get-DataCollection $search 'Servers') | Where-Object { $_ }) if (-not $servers.Count) { return } $check = Get-Text 'Check.Reference.Pointers' $found = @((Get-DataCollection $search 'Found') | Where-Object { $_ }) $places = @((Get-DataCollection $search 'Places') | Where-Object { $_ }) $looked = (Get-Text 'Value.Reference.Looked') -f (@($places | ForEach-Object { Get-ReferencePlaceText -Place $_ }) -join '; ') $reference = @(New-Reference -Section 'Title.Reference.Pointers') if (-not $found.Count) { return New-Finding -Category Network -Check $check -Severity INFO ` -Value ('{0} | {1}' -f ((Get-Text 'Value.Reference.None') -f ($servers -join ', ')), $looked) ` -Meaning (Get-Text 'Meaning.Reference.None') -Hint (Get-Text 'Hint.Reference.None') } $value = @() foreach ($server in $servers) { $mine = @($found | Where-Object { "$(Get-DataProperty $_ 'Server')" -eq $server }) if (-not $mine.Count) { continue } $items = @($mine | ForEach-Object { $shares = @((Get-DataCollection $_ 'Shares') | Where-Object { $_ }) $shown = @($shares | Select-Object -First 4) -join ', ' if ($shares.Count -gt 4) { $shown = (Get-Text 'Value.Reference.MoreShares') -f $shown, ($shares.Count - 4) } (Get-Text 'Value.Reference.Item') -f (Get-ReferencePlaceText -Place "$(Get-DataProperty $_ 'Place')"), $shown }) $value += '{0}: {1}' -f $server, ($items -join '; ') } $without = @($servers | Where-Object { $name = $_; -not @($found | Where-Object { "$(Get-DataProperty $_ 'Server')" -eq $name }).Count }) if ($without.Count) { $value += (Get-Text 'Value.Reference.NoneFor') -f ($without -join ', ') } # A hint for each place that holds one, each once, in the order of the places. $hints = @($found | ForEach-Object { "$(Get-DataProperty $_ 'Place')" } | Select-Object -Unique | ForEach-Object { Get-ReferencePlaceHint -Place $_ } | Where-Object { $_ } | Select-Object -Unique) $hints += Get-Text 'Hint.Reference.After' New-Finding -Category Network -Check $check -Severity WARN -Value ($value -join ' | ') ` -Meaning (Get-Text 'Meaning.Reference.Found') -Hint ($hints -join ' | ') -Reference $reference } function ConvertTo-NetworkDependencyReferenceSection { <# .SYNOPSIS Every place that points at a Server that was asked for in vain: the Server, the place, in how many files or keys, and the shares. Nothing where none does. Pure. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()]$Data) $found = @((Get-DataCollection (Get-DataProperty $Data 'References') 'Found') | Where-Object { $_ }) if (-not $found.Count) { return } New-Section -Title (Get-Text 'Title.Reference.Pointers') -Help (Get-Text 'Help.Reference.Pointers') -Row @( $found | ForEach-Object { $row = [ordered]@{} $row[(Get-Text 'Column.Reference.Server')] = "$(Get-DataProperty $_ 'Server')" $row[(Get-Text 'Column.Reference.Place')] = Get-ReferencePlaceText -Place "$(Get-DataProperty $_ 'Place')" $row[(Get-Text 'Column.Reference.Count')] = [int](ConvertTo-Number (Get-DataProperty $_ 'Count')) $row[(Get-Text 'Column.Reference.Shares')] = @((Get-DataCollection $_ 'Shares') | Where-Object { $_ }) -join ', ' [pscustomobject]$row } ) } |