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' } ) # 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). - Subnets: Key, Address, PrefixLength. Domains: Key. Sites: Key, Name, Domain. Namespaces: Key, Server, Name. Shares: Key, Server, Name. Services: Key, Server, Port, Url. Adapters: Key, Name, Measurement. Drives: Key. - 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 { $_ }) $events = @($TimedEvent | Where-Object { $_ -and "$(Get-DataProperty $_ 'Lane')" -eq 'Server' }) # 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] $addSighting = { param([string]$Name, [string]$Address, [string]$Role, $Measurement, $TimedEvent) $read = ConvertTo-NetworkMapSighting -Name $Name -Address $Address $sightings.Add([pscustomobject]@{ Name = $read.Name; Address = $read.Address; Role = $Role; Measurement = $Measurement; TimedEvent = $TimedEvent }) $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) { $null = & $addSighting "$(Get-DataProperty $e 'Subject')" "$(Get-DataProperty $e 'Address')" '' $null $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 if ($end.Thing -eq 'Server') { $sighting = & $addSighting $end.Name $end.Address $(if ($role) { $role.Role }) $null $null } else { & $remember $end if ($end.Thing -in 'Share', 'Service' -and $end.Server) { $sighting = & $addSighting $end.Server $end.Address '' $null $null } } [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) } }) # 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]@{} 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 } } } $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 } CheckName = "$(Get-DataProperty $links[$i] 'CheckName')" App = "$(Get-DataProperty $links[$i] 'App')" }) } [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) 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 } }) Links = @($mappedLinks) Coverage = Get-NetworkMapCoverageView -Coverage $Coverage } } 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). 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 and TimedEvent, 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] # 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 } 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) } $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 } $servers.Add($server) } if ($Name -and -not $server.Names.Contains($Name)) { $server.Names.Add($Name) } if ($Address -and -not $server.Addresses.Contains($Address)) { $server.Addresses.Add($Address) } if ($Role -and -not $server.Roles.Contains($Role)) { $server.Roles.Add($Role) } $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++) { $name = "$(Get-DataProperty $all[$i] 'Name')" $address = "$(Get-DataProperty $all[$i] 'Address')" $role = "$(Get-DataProperty $all[$i] '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). if ($name -and $address -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). $pool = $(if ($name -or -not $address) { $servers.ToArray() } else { @($servers | Where-Object { -not $_.Names.Count }) }) $serverOfSighting[$i] = & $place $all[$i] $name $address $role $pool } # 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. 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.ToArray() -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. [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 { $_ }) $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. .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. if ($sighting.Name -and $sighting.Address) { $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 -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 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 "Netzwerk im Bild" 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) $text = ConvertTo-ServerName -Name $Name $uri = $null if ($text -match '^[a-z][a-z0-9+.-]*://' -and [uri]::TryCreate($text, [UriKind]::Absolute, [ref]$uri) -and $uri.Host) { $text = ConvertTo-ServerName -Name $uri.Host } $at = ConvertTo-ServerAddress -Address $Address if ($text -and (Test-NetworkAddress -Text $text)) { if (-not $at) { $at = ConvertTo-ServerAddress -Address $text } $text = '' } [pscustomobject]@{ Name = $text; Address = $at } } # 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 = 2 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 ($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 } } shares = & $list 'Shares' { [ordered]@{ key = $_.Key; server = $_.Server; name = $_.Name } } adapters = & $list 'Adapters' { [ordered]@{ key = $_.Key } } drives = & $list 'Drives' { [ordered]@{ key = $_.Key } } 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)) } |