Private/NetworkMap.ps1
|
# The Network Map. # # Every Server, Subnet, Domain, Namespace, Service, share and mapped drive a Run observed, # with the Links between them and the Target Machine at their start. Assembled once, after # both Parts' Checks are merged and before the Leads are drawn, from the Links, the # Measurements and the Timed Events the Judges returned and from which Checks ran: the one # place that knows which Server is which and how things are connected. Written to the # Report folder as network.json (Write-NetworkJson). See ADR-0012. # # Pure: the same records give the same Map, and nothing is asked of the machine. # # Observed, never assumed. A Subnet is one an adapter of the Target Machine is in; a range # a view assumes around an address it cannot place is not in the Map. Two Links that # disagree are both kept, and a Server is in every Subnet one of its addresses is in. What # no Check that ran could have observed is unknown, not absent (Coverage.Unknown). # # A thing exists in the Map by being named - by a Link, a Measurement or a Timed Event. # There is no record per thing. # The Role a Link gives the Server at one of its ends, beside the Roles it was measured in: # a DNS server is one by being used as one. By kind, the end, and for LeadsTo and OnServer # only from the thing named. $script:NetworkMapLinkRoles = @( @{ Kind = 'UsesGateway'; End = 'To'; From = ''; Role = 'Gateway' } @{ Kind = 'UsesDns'; End = 'To'; From = ''; Role = 'DnsServer' } @{ Kind = 'UsesDhcp'; End = 'To'; From = ''; Role = 'DhcpServer' } @{ Kind = 'ControllerOf'; End = 'From'; From = ''; Role = 'DomainController' } @{ Kind = 'LeadsTo'; End = 'To'; From = 'Namespace'; Role = 'DfsTarget' } @{ Kind = 'OnServer'; End = 'To'; From = 'Share'; Role = 'FileServer' } ) # The port of the Service a Link of this kind uses its Server through: a DNS server answers # on 53, a DHCP server on 67. A gateway routes, and is used as a Server. $script:NetworkMapUsedPorts = @{ UsesDns = 53; UsesDhcp = 67 } # The Roles of a Measurement of a web address, whose Subject is the host the URL names. A # request asked by a name went to that name: contoso.example asked as # https://contoso.example/ is the web host of that name, never the domain's name standing for # the one Server at its address (#145) - another host answering at that address, a shared # web host, would otherwise take its Measurements, its Service and its row of the CSVs. $script:NetworkMapByUrlRoles = @('HttpsService', 'Website', 'WebsiteHost') # What a Check became in this Run, as the coverage says it. Only Ran observed anything. $script:NetworkMapCheckStatuses = @('Ran', 'Skipped', 'LeftOutByScope', 'LackedRights') function New-NetworkMapCoverage { <# .SYNOPSIS Which Checks of a Run ran, which the Technician skipped, which the Scope left out and which lacked their rights: what the Network Map reads "not looked at" from. Pure. .DESCRIPTION A missing Link is unknown where the Check that would have observed it did not run, as a Lead says what was not looked at (ADR-0005). A Check skipped by -Skip is Skipped, whatever else holds; one that lacked its rights is LackedRights; a Check the Scope left out, of the machine or of an application, is LeftOutByScope. .PARAMETER Check The Check Definitions the Run performs, of both Parts. .PARAMETER Skip What the Technician skipped, by Check Name or Kind. .PARAMETER LeftOut The Check Definitions the Scope left out. .PARAMETER LackedRights The Check Definitions that needed admin rights this Run did not have: the Elevated Part was refused, broke off, or was not asked for. .PARAMETER Scope The title of the Scope the Run was narrowed by; empty for a Run of everything. #> [CmdletBinding()] [OutputType([psobject])] param( [AllowNull()][AllowEmptyCollection()]$Check, [AllowNull()][AllowEmptyCollection()][string[]]$Skip = @(), [AllowNull()][AllowEmptyCollection()]$LeftOut, [AllowNull()][AllowEmptyCollection()]$LackedRights, [AllowNull()][AllowEmptyString()][string]$Scope = '' ) $nameOf = { param($Definition) $n = "$(Get-DataProperty $Definition 'Name')"; if (-not $n) { $n = "$(Get-DataProperty $Definition 'Kind')" }; $n } $lacking = @(@($LackedRights | Where-Object { $_ }) | ForEach-Object { & $nameOf $_ }) $entry = { param($Definition, [string]$Status) $parameters = ConvertTo-ParameterHashtable (Get-DataProperty $Definition 'Parameters') [pscustomobject]@{ Check = & $nameOf $Definition Kind = "$(Get-DataProperty $Definition 'Kind')" App = "$(Get-Parameter $parameters 'App' '')" Status = $Status } } $checks = @( foreach ($definition in @($Check | Where-Object { $_ })) { $status = if (Test-CheckSkipped -Definition $definition -Skip $Skip) { 'Skipped' } elseif ($lacking -contains (& $nameOf $definition)) { 'LackedRights' } else { 'Ran' } & $entry $definition $status } foreach ($definition in @($LeftOut | Where-Object { $_ })) { & $entry $definition 'LeftOutByScope' } ) [pscustomobject]@{ PSTypeName = 'Gutcheck.NetworkMapCoverage' Scope = "$Scope" Checks = $checks } } function New-NetworkMap { <# .SYNOPSIS The Network Map of a Run, from the Links, Measurements and Timed Events its Judges returned and the Checks that ran. Pure. .DESCRIPTION Which Server is which is decided here, once, and only by the identity module (Private/ServerIdentity.ps1): every record that names a Server - a Measurement by its Subject and Address, a Timed Event of a Server's lane, an end of a Link, the Server a share or a Service is on - is one sighting, in that order, by its name and address as ConvertTo-NetworkMapSighting reads them: of a web address its host. Which Server a sighting is, Select-NetworkMapServer says, and New-NetworkMapServer joins them: by its name where it has one, by its address only where it has none - two different FQDNs are never one, whatever address they share (#145). A Server's Key is its first FQDN, else its first name, else its first address, as ConvertTo-ServerName writes them, else its Role in brackets, "(Gateway)"; where two Servers would have one Key - two machines that share a short name - each is keyed by its address. A share and a Service are keyed by the Key of the Server they are on, so that a share named on \\FS01 and a Server measured as fs01.contoso.example meet. The Map holds: - Machine: the Target Machine (Key "machine"), with its own Measurements. - Servers: Key, Names, Addresses, Roles (as measured and as its Links give them, in the order of the Measurement's Roles), Subnets (the Keys of the observed Subnets one of its addresses is in), Measurement and TimedEvent (the records naming it), and ByUrl: whether it was sighted only as the host of a web address (New-NetworkMapServer). - Subnets: Key, Address, PrefixLength. Domains: Key. Sites: Key, Name, Domain. Namespaces: Key, Server, Name. Shares: Key, Server, Name, Service (the Key of the SMB Service of its Server, which it is offered through). Services: Key, Server, Port, Url, Name (what the Links that name it call it, as their Check Definitions name its port: each name once, ignoring case, in the order of the Links, joined by " / "; empty where none does), Apps (the App of each Uses Link to it), and the Measurement and TimedEvent of it: each record of its Server that names its port, or its URL (Get-NetworkMapServiceKey) - a Service a record names is made by it. Adapters: Key, Name, Measurement. Drives: Key. Apps: Key, the name of an App whose Check reached a Service (Uses). WindowsServices: Key, Name, and the TimedEvent of it: the crashes the logs held, by its service name. Printers: Key, Name, a printer the Target Machine prints to (PrintsTo), and Finding: the Ids of the Findings about it its Links name. - Links: Kind, From and To (each Thing and Key, as the Map keys it), CheckName, App; every one as it was observed, two that disagree both. - Coverage: Scope, Checks (Check, Kind, App, Status, and Links: the kinds of Link its Kind observes, by the thing each starts from, as Kind:From), Unknown: each Kind:From no Check that ran could have observed, and Unobservable: those of them no Kind of this module observes at all, whatever ran. .PARAMETER Coverage The Checks of the Run, as New-NetworkMapCoverage gives them. Without it nothing is known to have run, and every kind of Link is unknown. #> [CmdletBinding()] [OutputType([psobject])] param( [AllowNull()][AllowEmptyCollection()]$Link, [AllowNull()][AllowEmptyCollection()]$Measurement, [AllowNull()][AllowEmptyCollection()]$TimedEvent, [AllowNull()]$Coverage ) $links = @($Link | Where-Object { $_ }) $measured = @($Measurement | Where-Object { $_ }) # Read by their properties and not through Get-DataProperty: a Run hands thousands of # events over, and a call for each field of each cost seconds. A record lacks none of # these; one that did would read as empty all the same. $events = @(foreach ($e in @($TimedEvent)) { if ($e -and "$($e.Lane)" -eq 'Server') { $e } }) # Every record that names a Server, as one sighting each, in the order the Map reads # them. The Servers are made of all of them at once (New-NetworkMapServer), and each # sighting is known by its number among them. $sightings = New-Object System.Collections.Generic.List[psobject] # ByUrl: the name is the host a web address was asked by - a Measurement in one of # $script:NetworkMapByUrlRoles, a Service reached by its URL - which is never the # domain's name standing for its controllers (New-NetworkMapServer). $addSighting = { param([string]$Name, [string]$Address, [string]$Role, $Measurement, $TimedEvent, [bool]$ByUrl = $false) $read = ConvertTo-NetworkMapSighting -Name $Name -Address $Address $sightings.Add([pscustomobject]@{ Name = $read.Name; Address = $read.Address; Role = $Role; Measurement = $Measurement; TimedEvent = $TimedEvent; ByUrl = $ByUrl -or $script:NetworkMapByUrlRoles -contains $Role }) $sightings.Count - 1 } # The things other than Servers, by Key, in the order they were first named. $things = @{} foreach ($thing in $script:LinkThings) { $things[$thing] = [ordered]@{} } $machineMeasured = New-Object System.Collections.Generic.List[psobject] $adapterMeasured = @{} $remember = { param($End) $table = $things[$End.Thing] if (-not $table.Contains($End.Key)) { $table[$End.Key] = $End } if ($End.Thing -eq 'Site') { $domain = New-LinkEnd -Thing Domain -Name $End.Domain if (-not $things['Domain'].Contains($domain.Key)) { $things['Domain'][$domain.Key] = $domain } } } foreach ($m in $measured) { $role = "$(Get-DataProperty $m 'Role')" $subject = "$(Get-DataProperty $m 'Subject')".Trim() if ($role -eq 'Machine' -or ($role -eq 'Adapter' -and -not $subject)) { $machineMeasured.Add($m); continue } if ($role -eq 'Adapter') { $end = New-LinkEnd -Thing Adapter -Name $subject & $remember $end if (-not $adapterMeasured.ContainsKey($end.Key)) { $adapterMeasured[$end.Key] = New-Object System.Collections.Generic.List[psobject] } $adapterMeasured[$end.Key].Add($m) continue } $null = & $addSighting $subject "$(Get-DataProperty $m 'Address')" $role $m $null } foreach ($e in $events) { $read = ConvertTo-NetworkMapSighting -Name "$($e.Subject)" -Address "$($e.Address)" $sightings.Add([pscustomobject]@{ Name = $read.Name; Address = $read.Address; Role = ''; Measurement = $null; TimedEvent = $e }) } # Each Link's ends: a Server sighted, the Server a share or a Service is on sighted # too, everything else remembered by its Key. Sighting is the number of the end's # sighting, -1 for an end that names no Server. $ends = @(foreach ($l in $links) { $kind = "$(Get-DataProperty $l 'Kind')" $from = ConvertTo-LinkEnd -End (Get-DataProperty $l 'From') $to = ConvertTo-LinkEnd -End (Get-DataProperty $l 'To') $pair = foreach ($side in @(@('From', $from), @('To', $to))) { $end = $side[1] $role = @($script:NetworkMapLinkRoles | Where-Object { $_.Kind -eq $kind -and $_.End -eq $side[0] -and (-not $_.From -or $_.From -eq $from.Thing) }) | Select-Object -First 1 $sighting = -1 # The Server a Service reached by its URL is on is the host the URL names. $byUrl = $from.Thing -eq 'Service' -and [bool]"$($from.Url)" if ($end.Thing -eq 'Server') { $sighting = & $addSighting $end.Name $end.Address $(if ($role) { $role.Role }) $null $null $byUrl } else { & $remember $end if ($end.Thing -in 'Share', 'Service' -and $end.Server) { $sighting = & $addSighting $end.Server $end.Address '' $null $null ($end.Thing -eq 'Service' -and [bool]"$($end.Url)") } } [pscustomobject]@{ End = $end; Sighting = $sighting } } , @($pair) }) # The domain's names: a Domain the Links name, else the DNS suffix of a Server's FQDN. $domainNames = Get-NetworkMapDomainName -Domain @($things['Domain'].Keys) -Name @($sightings | ForEach-Object Name) $placed = New-NetworkMapServer -Sighting $sightings.ToArray() -DomainName $domainNames $subnets = @($things['Subnet'].Values | ForEach-Object { [pscustomobject]@{ Key = $_.Key; Address = $_.Address; PrefixLength = $_.PrefixLength } }) $roleOrder = { param($Role) $at = [array]::IndexOf($script:MeasurementRoleValues, $Role); if ($at -lt 0) { 99 } else { $at } } $servers = @(foreach ($server in $placed.Server) { $in = @($subnets | Where-Object { $subnet = $_ @($server.Addresses | Where-Object { $parsed = ConvertTo-NetworkAddress -Address $_ $parsed -and (Test-AddressInPrefix -Address $parsed -Network $subnet.Address -PrefixLength $subnet.PrefixLength) }).Count } | ForEach-Object Key) [pscustomobject]@{ Key = $server.Key Names = [string[]]@($server.Names) Addresses = [string[]]@($server.Addresses) Roles = [string[]]@($server.Roles | Sort-Object { & $roleOrder $_ }) Subnets = [string[]]$in Measurement = @($server.Measurement) TimedEvent = @($server.TimedEvent) ByUrl = [bool]$server.ByUrl } }) # A share and a Service as the Map keys them: by the Key of the Server they are on. $serverKeyOf = { param([int]$Sighting, [string]$Fallback) if ($Sighting -ge 0 -and $placed.Key[$Sighting]) { $placed.Key[$Sighting] } else { $Fallback } } $mapEnd = { param($Pair) $end = $Pair.End $key = $end.Key $server = $end.Server switch ($end.Thing) { 'Server' { $key = & $serverKeyOf $Pair.Sighting $end.Key } 'Share' { $server = & $serverKeyOf $Pair.Sighting $end.Server; $key = '\\{0}\{1}' -f $server, $end.Name } 'Service' { $server = & $serverKeyOf $Pair.Sighting $end.Server; if (-not $end.Url) { $key = '{0}:{1}' -f $server, $end.Port } } } [pscustomobject]@{ Thing = $end.Thing; Key = $key; Server = $server } } $mappedLinks = New-Object System.Collections.Generic.List[psobject] $shares = [ordered]@{} $services = [ordered]@{} # What the Links call each Service, by its Key: every name once, in the order the Links # came. Two Definitions that call one port differently are both kept, as two Links that # disagree are (ADR-0012). $serviceNames = @{} for ($i = 0; $i -lt $links.Count; $i++) { $pair = $ends[$i] $from = & $mapEnd $pair[0] $to = & $mapEnd $pair[1] foreach ($one in @(@($from, $pair[0].End), @($to, $pair[1].End))) { $mapped = $one[0]; $end = $one[1] if ($mapped.Thing -eq 'Share' -and -not $shares.Contains($mapped.Key)) { $shares[$mapped.Key] = [pscustomobject]@{ Key = $mapped.Key; Server = $mapped.Server; Name = $end.Name } } if ($mapped.Thing -eq 'Service' -and -not $services.Contains($mapped.Key)) { $services[$mapped.Key] = [pscustomobject]@{ Key = $mapped.Key; Server = $mapped.Server; Port = $end.Port; Url = $end.Url } } if ($mapped.Thing -eq 'Service' -and "$($end.Name)") { if (-not $serviceNames.ContainsKey($mapped.Key)) { $serviceNames[$mapped.Key] = New-Object System.Collections.Generic.List[string] } $named = $serviceNames[$mapped.Key] if (-not @($named | Where-Object { $_ -eq "$($end.Name)" }).Count) { $named.Add("$($end.Name)") } } } $mappedLinks.Add([pscustomobject]@{ Kind = "$(Get-DataProperty $links[$i] 'Kind')" From = [pscustomobject]@{ Thing = $from.Thing; Key = $from.Key } To = [pscustomobject]@{ Thing = $to.Thing; Key = $to.Key } Finding = "$(Get-DataProperty $links[$i] 'Finding')" CheckName = "$(Get-DataProperty $links[$i] 'CheckName')" App = "$(Get-DataProperty $links[$i] 'App')" }) } # Each record of a Server that names a port or a URL is that Service's as well: a TCP # connect to 445, the SMB drops the logs held of it, a URL's response. A ping names none, # and stays the Server's alone. foreach ($server in $servers) { foreach ($pair in @(@('Measurement', $server.Measurement), @('TimedEvent', $server.TimedEvent))) { foreach ($record in @($pair[1])) { $url = $(if ($pair[0] -eq 'Measurement') { "$($record.Group)" } else { "$($record.Url)" }) $key = Get-NetworkMapServiceKey -ServerKey $server.Key -Port $record.Port -Url $url if (-not $key) { continue } if (-not $services.Contains($key)) { $isUrl = $key -eq $url $services[$key] = [pscustomobject]@{ Key = $key; Server = $server.Key; Port = $(if ($isUrl) { $record.Port } else { [int]$record.Port }); Url = $(if ($isUrl) { $url } else { '' }) } } $service = $services[$key] if (-not $service.PSObject.Properties['Measurement']) { $service | Add-Member -NotePropertyName Measurement -NotePropertyValue (New-Object System.Collections.Generic.List[psobject]) $service | Add-Member -NotePropertyName TimedEvent -NotePropertyValue (New-Object System.Collections.Generic.List[psobject]) } $service.($pair[0]).Add($record) } } } # A share is reached over SMB: its Server offers the SMB Service, which the share is # offered through - made here where no record or Link named it, as a Service a record # names is. foreach ($share in @($shares.Values)) { $key = Get-NetworkMapServiceKey -ServerKey "$($share.Server)" -Port 445 if (-not $key) { continue } if (-not $services.Contains($key)) { $services[$key] = [pscustomobject]@{ Key = $key; Server = "$($share.Server)"; Port = 445; Url = '' } } $share | Add-Member -NotePropertyName Service -NotePropertyValue $key -Force } # A DNS server and a DHCP server are used as such through their Services, on 53 and 67: # the machine asks the service, not the Server. Made here where no record named it, as # the SMB Service of a share is. foreach ($link in $mappedLinks) { if (-not $script:NetworkMapUsedPorts.ContainsKey($link.Kind) -or $link.To.Thing -ne 'Server') { continue } $port = $script:NetworkMapUsedPorts[$link.Kind] $key = Get-NetworkMapServiceKey -ServerKey "$($link.To.Key)" -Port $port if (-not $key) { continue } if (-not $services.Contains($key)) { $services[$key] = [pscustomobject]@{ Key = $key; Server = "$($link.To.Key)"; Port = $port; Url = '' } } } foreach ($service in @($services.Values)) { if (-not $service.PSObject.Properties['Measurement']) { $service | Add-Member -NotePropertyName Measurement -NotePropertyValue (New-Object System.Collections.Generic.List[psobject]) $service | Add-Member -NotePropertyName TimedEvent -NotePropertyValue (New-Object System.Collections.Generic.List[psobject]) } # What it is called and who uses it, for the Report to say which Service a Finding is # about. A Service only a record, a share or the DNS made is called nothing. $named = $serviceNames[$service.Key] $service | Add-Member -NotePropertyName Name -NotePropertyValue $(if ($named) { $named -join ' / ' } else { '' }) $service | Add-Member -NotePropertyName Apps -NotePropertyValue ([string[]]@($mappedLinks | Where-Object { $_.Kind -eq 'Uses' -and $_.To.Thing -eq 'Service' -and $_.To.Key -eq $service.Key } | ForEach-Object { $_.From.Key } | Select-Object -Unique)) } [pscustomobject]@{ PSTypeName = 'Gutcheck.NetworkMap' Machine = [pscustomobject]@{ Key = 'machine'; Measurement = @($machineMeasured) } Servers = $servers Subnets = $subnets Domains = @($things['Domain'].Values | ForEach-Object { [pscustomobject]@{ Key = $_.Key } }) Sites = @($things['Site'].Values | ForEach-Object { [pscustomobject]@{ Key = $_.Key; Name = $_.Name; Domain = $_.Domain } }) Namespaces = @($things['Namespace'].Values | ForEach-Object { [pscustomobject]@{ Key = $_.Key; Server = $_.Server; Name = $_.Name } }) Services = @($services.Values | ForEach-Object { $_ }) Shares = @($shares.Values) Adapters = @($things['Adapter'].GetEnumerator() | ForEach-Object { $measuredHere = $adapterMeasured[$_.Key] [pscustomobject]@{ Key = $_.Value.Key; Name = $_.Value.Name; Measurement = @($(if ($measuredHere) { $measuredHere })) } }) Drives = @($things['Drive'].Values | ForEach-Object { [pscustomobject]@{ Key = $_.Key } }) Apps = @($things['App'].Values | ForEach-Object { [pscustomobject]@{ Key = $_.Key } }) Printers = @($things['Printer'].Values | ForEach-Object { $key = $_.Key [pscustomobject]@{ Key = $key; Name = $_.Name; Finding = [string[]]@($mappedLinks | Where-Object { $_.From.Thing -eq 'Printer' -and $_.From.Key -eq $key -and $_.Finding } | ForEach-Object { $_.Finding } | Select-Object -Unique) } }) WindowsServices = @($things['WindowsService'].Values | ForEach-Object { $key = $_.Key [pscustomobject]@{ Key = $key; Name = $_.Name; TimedEvent = @(foreach ($e in @($TimedEvent)) { if ($e -and "$($e.Lane)" -eq 'Marker' -and "$($e.Subject)".Trim().ToLowerInvariant() -eq $key) { $e } }) } }) Links = @($mappedLinks) Coverage = Get-NetworkMapCoverageView -Coverage $Coverage } } function Get-NetworkMapServiceKey { <# .SYNOPSIS The Key of the Service a record of a Server is about: its URL where it names one, else the Server's Key and the port it names; nothing for a record of neither - a ping. Pure. .DESCRIPTION The one rule a record is put on its Service by, here on the Map and in the views, as New-LinkEnd keys a Service: "fs01.contoso.example:445", or the URL. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyString()][string]$ServerKey, [AllowNull()]$Port, [AllowNull()][AllowEmptyString()][string]$Url = '') $address = "$Url".Trim() # A URL as Split-ServiceSubject reads one: a share path in Group is no URL. if (Test-ServiceUrl -Text $address) { return $address } $number = ConvertTo-Number $Port if (-not "$ServerKey" -or $null -eq $number) { return '' } '{0}:{1}' -f $ServerKey, [int]$number } function New-NetworkMapServer { <# .SYNOPSIS The Servers of a Network Map, made of every sighting of one, and the Key of the Server each sighting is. Pure. .DESCRIPTION A sighting is the name, the address and the Role a record names a Server by, as ConvertTo-NetworkMapSighting reads them, with the Measurement or the Timed Event it is of. It is the Servers Select-NetworkMapServer says it is, joined into one, else a Server of its own. A sighting of an address alone is placed last, once every name is: it is the one Server that has that address, and which Servers have it is known only then; where none or several do, it stays a Server of its own, keyed by the address. The domain's name at an address counts as that address alone: it is the one Server there, never two, and no Server of its own where its address places it (#145) - but for a sighting ByUrl, the host a web address was asked by, which is placed by its name as any name is. A Server sighted only ByUrl does not count among the Servers at its address for the domain's name: the web host https://contoso.example/ resolves to is no second holder of the controller's address. A sighting of nothing - no name, no address, no Role - is no Server. A Server's Key is its first FQDN, else its first name, else its first address, else its Role in brackets, "(Gateway)", which no name or address is; where two Servers would have one Key - two machines that share a short name - each is keyed by its address. Returns Server - each with Key, Names, Addresses, Roles, Measurement, TimedEvent and ByUrl (every sighting of it was ByUrl), in the order they were first sighted - and Key: the Key of the Server of each sighting, in their order, empty for a sighting of nothing. .PARAMETER Sighting Each with Name, Address, Role, and the Measurement or the TimedEvent it is of. .PARAMETER DomainName The names that are a Domain's (Get-NetworkMapDomainName). A sighting of one at an address is the one Server at that address, as an address alone is. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyCollection()]$Sighting, [AllowNull()][AllowEmptyCollection()][string[]]$DomainName = @()) $servers = New-Object System.Collections.Generic.List[psobject] # How often the Servers have changed - one added, two joined, a name, an address or a # Role added to one. A sighting the same as one placed while they stood as they stand # now is the same Server, and is not asked again: a Run logs thousands of events of a # few Servers, and asking for each cost the Report half a minute. $changes = @{ Count = 0 } $placedAs = @{} # A Server joined into another points to the one it became part of (MergedInto), so that # a sighting placed before the joining still finds its Server. $survivorOf = { param($Server) while ($Server.MergedInto) { $Server = $Server.MergedInto }; $Server } $merge = { param($Server, $Other) if ([object]::ReferenceEquals($Server, $Other)) { return } $changes.Count++ foreach ($x in $Other.Names) { if (-not $Server.Names.Contains($x)) { $Server.Names.Add($x) } } foreach ($x in $Other.Addresses) { if (-not $Server.Addresses.Contains($x)) { $Server.Addresses.Add($x) } } foreach ($x in $Other.Roles) { if (-not $Server.Roles.Contains($x)) { $Server.Roles.Add($x) } } foreach ($x in $Other.Measurement) { $Server.Measurement.Add($x) } foreach ($x in $Other.TimedEvent) { $Server.TimedEvent.Add($x) } $Server.ByUrl = $Server.ByUrl -and $Other.ByUrl $Other.MergedInto = $Server $null = $servers.Remove($Other) } # A sighting placed among the Servers of a pool: the ones it is, joined, else a new one. $place = { param($One, [string]$Name, [string]$Address, [string]$Role, $Pool) $found = @(Select-NetworkMapServer -Server $Pool -Name $Name -Address $Address -Role $Role) if ($found.Count) { $server = $found[0] foreach ($other in @($found | Select-Object -Skip 1)) { & $merge $server $other } } else { $server = [pscustomobject]@{ Key = ''; Names = New-Object System.Collections.Generic.List[string]; Addresses = New-Object System.Collections.Generic.List[string] Roles = New-Object System.Collections.Generic.List[string]; Measurement = New-Object System.Collections.Generic.List[psobject] TimedEvent = New-Object System.Collections.Generic.List[psobject]; MergedInto = $null ByUrl = [bool](Get-DataProperty $One 'ByUrl') } $servers.Add($server) $changes.Count++ } if ($Name -and -not $server.Names.Contains($Name)) { $server.Names.Add($Name); $changes.Count++ } if ($Address -and -not $server.Addresses.Contains($Address)) { $server.Addresses.Add($Address); $changes.Count++ } if ($Role -and -not $server.Roles.Contains($Role)) { $server.Roles.Add($Role); $changes.Count++ } if ($server.ByUrl -and -not (Get-DataProperty $One 'ByUrl')) { $server.ByUrl = $false; $changes.Count++ } $measured = Get-DataProperty $One 'Measurement' $logged = Get-DataProperty $One 'TimedEvent' if ($null -ne $measured) { $server.Measurement.Add($measured) } if ($null -ne $logged) { $server.TimedEvent.Add($logged) } $server } $all = @($Sighting) $serverOfSighting = New-Object System.Collections.Generic.List[psobject] $domainAtAddress = New-Object System.Collections.Generic.List[int] for ($i = 0; $i -lt $all.Count; $i++) { # By its properties, as New-NetworkMap made it: thousands of sightings a Run. $one = $all[$i] $name = "$($one.Name)" $address = "$($one.Address)" $role = "$($one.Role)" $serverOfSighting.Add($null) if (-not $name -and -not $address -and -not $role) { continue } # The domain's name at an address waits for every other Server (below) - but for a # web address asked by it, which is the host of that name. if ($name -and $address -and -not $one.ByUrl -and @($DomainName) -contains $name) { $domainAtAddress.Add($i); continue } # An address alone is placed among the Servers of no name for now, and joins a named # one only once every name is placed (below). $among = $(if ($name -or -not $address) { 'all' } else { 'unnamed' }) $same = '{0}|{1}|{2}|{3}|{4}' -f $name, $address, $role, $among, [bool]$one.ByUrl $before = $placedAs[$same] if ($before -and $before.Stable -and $before.Changes -eq $changes.Count) { # Placed so twice, the second time changing nothing, and nothing changed since: the # same question of the same Servers, so the same Server, and its record on it. $measured = $one.Measurement $logged = $one.TimedEvent if ($null -ne $measured) { $before.Server.Measurement.Add($measured) } if ($null -ne $logged) { $before.Server.TimedEvent.Add($logged) } $serverOfSighting[$i] = $before.Server continue } $pool = $(if ($among -eq 'all') { $servers.ToArray() } else { @($servers | Where-Object { -not $_.Names.Count }) }) $unchanged = $changes.Count $serverOfSighting[$i] = & $place $all[$i] $name $address $role $pool $stable = $null -ne $before -and $before.Changes -eq $unchanged -and $changes.Count -eq $unchanged -and [object]::ReferenceEquals($before.Server, $serverOfSighting[$i]) $placedAs[$same] = @{ Changes = $changes.Count; Server = $serverOfSighting[$i]; Stable = $stable } } # Each address alone, now that every name is placed: the one named Server that has it. foreach ($bare in @($servers | Where-Object { -not $_.Names.Count -and $_.Addresses.Count })) { $holder = @(Select-NetworkMapServer -Server @($servers | Where-Object { $_.Names.Count }) -Address $bare.Addresses[0]) if ($holder.Count -eq 1) { & $merge $holder[0] $bare } } # The domain's name at an address - a time source, a share of the domain's own, logged # at the controller that answered - is the one Server at that address, as an address # alone is, and does not become a name of it: it is no Server of its own beside the # controllers (#145). Where its address places it on none or on several, it is a Server # by its name, as any name is. A Server sighted only ByUrl holds its address for no one # else: https://contoso.example/ resolving to the controller is the web host of that # name, never a second Server at the controller's address. foreach ($i in $domainAtAddress) { $name = "$(Get-DataProperty $all[$i] 'Name')" $address = "$(Get-DataProperty $all[$i] 'Address')" $role = "$(Get-DataProperty $all[$i] 'Role')" $holder = @(Select-NetworkMapServer -Server @($servers | Where-Object { -not $_.ByUrl }) -Address $address) $serverOfSighting[$i] = $(if ($holder.Count -eq 1) { & $place $all[$i] '' $address $role $holder } else { & $place $all[$i] $name $address $role $servers.ToArray() }) } # Each Server's Key, its own where no other Server would have it. $keyCount = @{} foreach ($server in $servers) { $server.Key = "$(@(@($server.Names | Where-Object { $_.Contains('.') }) + @($server.Names) + @($server.Addresses))[0])" if (-not $server.Key) { $server.Key = '({0})' -f $server.Roles[0] } $keyCount[$server.Key] = 1 + [int]$keyCount[$server.Key] } foreach ($server in $servers) { if ($keyCount[$server.Key] -gt 1 -and $server.Addresses.Count) { $server.Key = $server.Addresses[0] } } $keys = New-Object System.Collections.Generic.List[string] foreach ($server in $serverOfSighting) { $keys.Add($(if ($null -ne $server) { (& $survivorOf $server).Key } else { '' })) } [pscustomobject]@{ Server = $servers.ToArray(); Key = $keys.ToArray() } } function Select-NetworkMapServer { <# .SYNOPSIS The Servers a name and an address are, by the Network Map's one rule: what the Map joins a sighting to, and where a view finds a record or a Subject. Pure. .DESCRIPTION By the name where there is one, by the address only where there is none - no record is matched by an address it shares with another (#145): - A name is each Server one of whose names is the same Server (Test-NetworkMapServerName): two different FQDNs are never one. A FQDN is one machine at every address it was logged at. A short name with an address keeps apart from a Server of that name that has another address of the same family - two machines can share a short name - while an IPv4 and an IPv6 address say nothing of each other. - A name without an address that several Servers with an address are - a short name logged at two machines - stands for none of them: only for one without an address, or the one whose FQDN it is. - A short name that is two Servers of two FQDNs - FS01 beside fs01.contoso.example and fs01.fabrikam.example - is the one its address puts it with, else one of no FQDN. - An address alone is the one Server that has it; where several do, the one of them with no name - the address on its own - else none. - Neither - a gateway that is not there - is the Server of its Role that has neither. Returns each Server it is, in the order handed in; nothing where it is none. .PARAMETER Server The Servers to choose from, each with Names, Addresses and Roles as the Map keeps them. .PARAMETER Name The name, as ConvertTo-NetworkMapSighting reads it. .PARAMETER Address The address, likewise. #> [CmdletBinding()] [OutputType([psobject])] param( [AllowNull()][AllowEmptyCollection()]$Server, [AllowNull()][AllowEmptyString()][string]$Name = '', [AllowNull()][AllowEmptyString()][string]$Address = '', [AllowNull()][AllowEmptyString()][string]$Role = '' ) $servers = @($Server | Where-Object { $_ }) $fqdnsOf = { param($One) @(@($One.Names) | Where-Object { $_.Contains('.') }) } $isFqdn = $Name.Contains('.') if ($Name) { $family = $(if ($Address) { (ConvertTo-NetworkAddress -Address $Address).AddressFamily }) $named = @($servers | Where-Object { $addresses = @($_.Addresses) if ($isFqdn -and @($_.Names) -contains $Name) { return $true } if (-not (Test-NetworkMapServerName -Server $_ -Name $Name)) { return $false } -not $Address -or $addresses -contains $Address -or -not @($addresses | Where-Object { (ConvertTo-NetworkAddress -Address $_).AddressFamily -eq $family }).Count }) if (-not $Address -and @($named | Where-Object { @($_.Addresses).Count }).Count -gt 1) { $named = @($named | Where-Object { -not @($_.Addresses).Count -or ($isFqdn -and @($_.Names) -contains $Name) }) } if (@($named | ForEach-Object { & $fqdnsOf $_ } | Select-Object -Unique).Count -gt 1) { $atAddress = @($named | Where-Object { $Address -and @($_.Addresses) -contains $Address }) $oneMachine = $atAddress.Count -and @($atAddress | ForEach-Object { & $fqdnsOf $_ } | Select-Object -Unique).Count -le 1 $named = @(if ($oneMachine) { $atAddress } else { $named | Where-Object { -not (& $fqdnsOf $_).Count } }) } return $named } if ($Address) { $holding = @($servers | Where-Object { @($_.Addresses) -contains $Address }) if ($holding.Count -le 1) { return $holding } return @($holding | Where-Object { -not @($_.Names).Count }) } if (-not $Role) { return } @($servers | Where-Object { -not @($_.Names).Count -and -not @($_.Addresses).Count -and @($_.Roles) -contains $Role }) } function Test-NetworkMapServerName { <# .SYNOPSIS Whether a Server of the Map is a name, by the identity module: one of its names is the same Server (Test-SameServer), and for a FQDN it has no other FQDN. Pure. .DESCRIPTION Two different FQDNs are never one, though a short name of the one begins the other: fs01.fabrikam.example is not the Server of fs01.contoso.example and FS01. #> [CmdletBinding()] [OutputType([bool])] param([AllowNull()]$Server, [AllowNull()][AllowEmptyString()][string]$Name) $names = @(@($Server.Names) | Where-Object { $_ }) if (-not $Name -or -not @($names | Where-Object { Test-SameServer -Name $Name -Other $_ }).Count) { return $false } -not ($Name.Contains('.') -and @($names | Where-Object { $_.Contains('.') -and $_ -ne $Name }).Count) } function Get-NetworkMapDomainName { <# .SYNOPSIS The names that are a Domain's name, for the identity of a Server: each Domain the Map holds, and each name that is the DNS suffix of another's - contoso.example of dc01.contoso.example. Pure. .DESCRIPTION A time source or a share of the domain's own named by the domain's name is the controller that answered, not a Server of its own beside the controllers (#145). A Run with the Domain Check knows the Domain from its Links (ControllerOf, NamespaceOf, a site's); one without it knows the domain's name only as the suffix of the FQDNs of the Servers in it. .PARAMETER Domain The Keys of the Domains on the Map. .PARAMETER Name Every name a Server is named by, as ConvertTo-NetworkMapSighting reads it. #> [CmdletBinding()] [OutputType([string[]])] param([AllowNull()][AllowEmptyCollection()][string[]]$Domain, [AllowNull()][AllowEmptyCollection()][string[]]$Name) $names = @(@($Name) | Where-Object { $_ } | Select-Object -Unique) $suffixes = @($names | Where-Object { $suffix = '.' + $_ $_.Contains('.') -and @($names | Where-Object { $_.EndsWith($suffix, [StringComparison]::OrdinalIgnoreCase) }).Count }) [string[]]@(@(@($Domain) | Where-Object { $_ }) + $suffixes | Select-Object -Unique) } function Get-NetworkMapCoverageView { # The coverage as the Map carries it: each Check with the kinds of Link its Kind # observes ($script:<Kind>Links), each Kind:From ($script:LinkObservations) no Check that # ran could have observed, and of those the ones no Kind this module ships observes. What # the Run adds itself ($script:LinkObservationsOfTheRun) is known where what it adds it # to is, and observable where that is. [CmdletBinding()] [OutputType([psobject])] param([AllowNull()]$Coverage) $checks = @((Get-DataCollection $Coverage 'Checks') | Where-Object { $_ } | ForEach-Object { $kind = "$(Get-DataProperty $_ 'Kind')" $observes = @() if ($kind) { $observes = @(Get-KindDeclaration -Kind $kind -Name 'Links') | Where-Object { $_ } } [pscustomobject]@{ Check = "$(Get-DataProperty $_ 'Check')" Kind = $kind App = "$(Get-DataProperty $_ 'App')" Status = "$(Get-DataProperty $_ 'Status')" Links = [string[]]@($observes) } }) $known = @($checks | Where-Object { $_.Status -eq 'Ran' } | ForEach-Object { $_.Links }) $observable = @(@($script:KindVocabulary.Keys) | ForEach-Object { Get-KindDeclaration -Kind $_ -Name 'Links' } | Where-Object { $_ }) foreach ($added in $script:LinkObservationsOfTheRun.Keys) { if ($known -contains $script:LinkObservationsOfTheRun[$added]) { $known += $added } if ($observable -contains $script:LinkObservationsOfTheRun[$added]) { $observable += $added } } $unknown = @($script:LinkObservations | Where-Object { $known -notcontains $_ }) [pscustomobject]@{ Scope = "$(Get-DataProperty $Coverage 'Scope')" Checks = $checks Unknown = [string[]]$unknown Unobservable = [string[]]@($unknown | Where-Object { $observable -notcontains $_ }) } } function Find-NetworkMapServer { <# .SYNOPSIS The Server of the Network Map a name or an address is, or nothing. Pure. .DESCRIPTION By the Map's own rule (Select-NetworkMapServer), so that a record is found on the Server the Map put it on - and so is a copy of it, which says the same: by its name where it has one, by its address only where it has none, by its Role where it has neither; a web address by its host; the domain's name at an address as the one Server there (Get-NetworkMapDomainName). How a view finds the Server of a record, and of a Finding's Subject. Where that is several Servers, the first whose Key is among -Prefer, else the first. Where it is none, a name is the one Server that carries it as it is, at the address where one is given: a short name the Map put with its one FQDN before a Link named a second. Else it is of the Servers among -Prefer the first it is by name - FS01 of a Finding about fs01.fabrikam.example, beside fs01.contoso.example - and else nothing: FS01 alone does not say which of the two it is (ADR-0013). .PARAMETER Role The Role of a Measurement, which finds the one Server measured without a name or an address: a gateway that is not there. A Role of a web address ($script:NetworkMapByUrlRoles) finds the domain's name by that name, as the Map placed it. .PARAMETER Prefer The Keys of the Servers to take where a name is several: for a Finding's part, those of the Servers that hold a record of the Finding. #> [CmdletBinding()] [OutputType([psobject])] param( [Parameter(Mandatory)]$Map, [AllowNull()][AllowEmptyString()][string]$Name = '', [AllowNull()][AllowEmptyString()][string]$Address = '', [AllowNull()][AllowEmptyString()][string]$Role = '', [AllowNull()][AllowEmptyCollection()][string[]]$Prefer = @() ) $servers = @((Get-DataCollection $Map 'Servers') | Where-Object { $_ }) $sighting = ConvertTo-NetworkMapSighting -Name $Name -Address $Address # The domain's name at an address is the one Server there, as the Map placed it - but # for a web address asked by it, which the Map placed by its name. if ($sighting.Name -and $sighting.Address -and $script:NetworkMapByUrlRoles -notcontains $Role) { $domainNames = Get-NetworkMapDomainName -Domain @((Get-DataCollection $Map 'Domains') | ForEach-Object { "$($_.Key)" }) -Name @(@($servers | ForEach-Object { $_.Names }) + $sighting.Name) if (@($domainNames) -contains $sighting.Name) { $holder = @(Select-NetworkMapServer -Server @($servers | Where-Object { -not (Get-DataProperty $_ 'ByUrl') }) -Address $sighting.Address) if ($holder.Count -eq 1) { return $holder[0] } } } $found = @(Select-NetworkMapServer -Server $servers -Name $sighting.Name -Address $sighting.Address -Role $Role) if (-not $found.Count -and $sighting.Name) { $carrying = @($servers | Where-Object { @($_.Names) -contains $sighting.Name -and (-not $sighting.Address -or @($_.Addresses) -contains $sighting.Address) }) $found = @(if ($carrying.Count -eq 1) { $carrying } else { $servers | Where-Object { @($Prefer) -ccontains $_.Key -and (Test-NetworkMapServerName -Server $_ -Name $sighting.Name) } }) } $preferred = @($found | Where-Object { @($Prefer) -ccontains $_.Key }) | Select-Object -First 1 if ($preferred) { return $preferred } if ($found.Count) { return $found[0] } $null } function Find-NetworkMapService { <# .SYNOPSIS The Service of the Network Map of a key, or nothing. Pure. .DESCRIPTION By its Key as it is written, as the Map keys a Service (Get-NetworkMapServiceKey): the Key of its Server and the port, or the URL. A key a view or a Lead made for a Service of a part is found here; how a part is read into one is Get-ReportNetworkPartKey's. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()]$Map, [AllowNull()][AllowEmptyString()][string]$Key) if (-not $Key) { return $null } $found = @((Get-DataCollection $Map 'Services') | Where-Object { $_ -and "$($_.Key)" -ceq $Key } | Select-Object -First 1) if ($found.Count) { return $found[0] } $null } function Get-NetworkMapServerKey { <# .SYNOPSIS The key of the Server a Subject names, as the Report draws it: the Key of the Network Map's Server it is, else the identity module's key among the Run's Subjects. Pure. .DESCRIPTION What a Lead groups its Findings by and a Reference that keeps to one Server compares (ADR-0013), so that neither names a Server "Big Picture" draws as two: FS01 and an address the Map holds fs01.contoso.example at are one; fs01.a.example and fs01.b.example two, at one address too. Find-NetworkMapServer finds it. Where there is no Map - a Report made without a Run - or the Map has no such Server - a program, a Server no Check observed - Get-ServerKey among -Among. .PARAMETER Among Every Subject of the Run, for Get-ServerKey: what tells which FQDN a short name is. #> [CmdletBinding()] [OutputType([string])] param( [AllowNull()][AllowEmptyString()][string]$Name, [AllowNull()][AllowEmptyCollection()][string[]]$Among, [AllowNull()]$Map ) if ($Name -and $null -ne $Map) { $server = Find-NetworkMapServer -Map $Map -Name $Name if ($server) { return "$($server.Key)" } } Get-ServerKey -Name $Name -Among $Among } function ConvertTo-NetworkMapSighting { <# .SYNOPSIS The name and the address a record names a Server by, as the Map compares them. Pure. .DESCRIPTION The name as ConvertTo-ServerName writes it - without its port, UNC path, trailing dot or case - and of a web address its host: a website's page, measured through a proxy too, is the host it was loaded from. A name that is an address is the address. The address as ConvertTo-ServerAddress writes it. Either empty where there is none. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyString()][string]$Name, [AllowNull()][AllowEmptyString()][string]$Address) # Read once for each way it is written: a Run names a few Servers in thousands of events, # and the Map and the Report each read every one. $asked = '{0}|{1}' -f $Name, $Address $known = $null if ($script:NetworkMapSightingRead.TryGetValue($asked, [ref]$known)) { return $known } $text = ConvertTo-ServerName -Name $Name if (Test-ServiceUrl -Text $text) { $text = ConvertTo-ServerName -Name ([uri]$text).Host } $at = ConvertTo-ServerAddress -Address $Address if ($text -and (Test-NetworkAddress -Text $text)) { if (-not $at) { $at = ConvertTo-ServerAddress -Address $text } $text = '' } $read = [pscustomobject]@{ Name = $text; Address = $at } $script:NetworkMapSightingRead[$asked] = $read $read } # What ConvertTo-NetworkMapSighting read, by what it was given, exactly as given. It is pure, # so what it read once it reads every time. $script:NetworkMapSightingRead = New-Object 'System.Collections.Generic.Dictionary[string,psobject]' ([StringComparer]::Ordinal) # The version of network.json's shape. Raised when a field changes meaning or goes; a field # added is read past by whoever does not know it. 2: coverage.unknown names a kind where any # Link of it may be missing, no longer only where all may be; an adapter's key is its alias in # lower case; and two Servers that only share an address are two. $script:NetworkJsonVersion = 5 function ConvertTo-NetworkJsonDocument { <# .SYNOPSIS The Network Map as network.json writes it: a version, the things by kind, the Links and the coverage. Pure. .DESCRIPTION The coverage says each Check's Links and what is unknown twice: as kinds (links, unknown) - a kind is unknown where any Link of it may be missing, so that a reader of the kinds alone errs to unknown - and by the thing each starts from (observes, unknownLinks: kind and from), since one kind can be two Checks': LeadsTo from a Namespace is the Domain Check's, from a drive the Network Check's. Version 1 named a kind unknown only where no Check that ran observed any of it; that meaning changed, and so did the version, to 2. Version 3 added a thing, apps, and a kind of Link, Uses, a reader of 2 did not know; version 4 another thing, windowsServices, and the kind of Link HasService; version 5 another thing, printers, and the kind of Link PrintsTo ($script:NetworkJsonVersion). No Measurements, Timed Events or Findings: a reader joins them by a Server's key - the Subject of a row of measurements.csv or timeline.csv, and each Server of the Subjects of findings.csv, is that key (Get-CsvServerName) - and by a Finding's Id. A Map that is not there is written as the Map of a Run that observed nothing: its skeleton, with every kind of Link unknown. #> [CmdletBinding()] [OutputType([System.Collections.Specialized.OrderedDictionary])] param([AllowNull()]$Map) if ($null -eq $Map) { $Map = New-NetworkMap } # As a list however many there are: none is [], not null, and one is [x]. $list = { param($Name, [scriptblock]$Shape) , @((Get-DataCollection $Map $Name) | Where-Object { $_ } | ForEach-Object $Shape) } $end = { param($End) [ordered]@{ thing = "$($End.Thing)"; key = "$($End.Key)" } } $coverage = Get-DataProperty $Map 'Coverage' # Kind:From, as the kinds alone, each once, and as kind and from. $kindsOf = { param($Observed) @($Observed) | Where-Object { $_ } | ForEach-Object { ("$_" -split ':')[0] } | Select-Object -Unique } $pairsOf = { param($Observed) @($Observed) | Where-Object { $_ } | ForEach-Object { $one = "$_" -split ':'; [ordered]@{ kind = $one[0]; from = $one[1] } } } [ordered]@{ version = $script:NetworkJsonVersion servers = & $list 'Servers' { [ordered]@{ key = $_.Key; names = @($_.Names); addresses = @($_.Addresses); roles = @($_.Roles); subnets = @($_.Subnets) } } subnets = & $list 'Subnets' { [ordered]@{ key = $_.Key; address = $_.Address; prefixLength = $_.PrefixLength } } domains = & $list 'Domains' { [ordered]@{ key = $_.Key } } sites = & $list 'Sites' { [ordered]@{ key = $_.Key; name = $_.Name; domain = $_.Domain } } namespaces = & $list 'Namespaces' { [ordered]@{ key = $_.Key; server = $_.Server; name = $_.Name } } services = & $list 'Services' { [ordered]@{ key = $_.Key; server = $_.Server; port = $_.Port; url = $_.Url; name = "$($_.Name)" } } shares = & $list 'Shares' { [ordered]@{ key = $_.Key; server = $_.Server; name = $_.Name; service = "$($_.Service)" } } adapters = & $list 'Adapters' { [ordered]@{ key = $_.Key } } drives = & $list 'Drives' { [ordered]@{ key = $_.Key } } apps = & $list 'Apps' { [ordered]@{ key = $_.Key } } windowsServices = & $list 'WindowsServices' { [ordered]@{ key = $_.Key; name = $_.Name } } printers = & $list 'Printers' { [ordered]@{ key = $_.Key; name = $_.Name } } links = & $list 'Links' { [ordered]@{ kind = $_.Kind; from = (& $end $_.From); to = (& $end $_.To); check = $_.CheckName; app = $_.App } } coverage = [ordered]@{ scope = "$(Get-DataProperty $coverage 'Scope')" checks = @((Get-DataCollection $coverage 'Checks') | Where-Object { $_ } | ForEach-Object { [ordered]@{ check = $_.Check; kind = $_.Kind; app = $_.App; status = $_.Status links = @(& $kindsOf $_.Links); observes = @(& $pairsOf $_.Links) } }) unknown = @(& $kindsOf (Get-DataCollection $coverage 'Unknown')) unknownLinks = @(& $pairsOf (Get-DataCollection $coverage 'Unknown')) } } } function Write-NetworkJson { <# .SYNOPSIS Writes a Run's Network Map to network.json, beside the CSVs, for Second Level. .DESCRIPTION UTF-8 without the byte order mark Windows PowerShell would put before it: the file is read by tools, not by German Excel. Written with its skeleton when the Run observed nothing, as the CSVs are written with their header: a missing file and a Run without the network Checks must not look the same on disk. ADR-0012. #> [CmdletBinding()] [OutputType([void])] param( [AllowNull()]$Map, [Parameter(Mandatory)][string]$Path ) $json = ConvertTo-NetworkJsonDocument -Map $Map | ConvertTo-Json -Depth 8 [IO.File]::WriteAllText($Path, $json, (New-Object System.Text.UTF8Encoding $false)) } |