Private/Link.ps1
|
# The Link primitive. # # A Link is one connection between two things on the network a Check observed: the adapter # the Target Machine has, the Subnet that adapter is in, the DNS server it uses, the share a # mapped drive leads to and the Server that share is on. It is what the Network Map is # assembled from (Private/NetworkMap.ps1), beside the Measurements and Timed Events that # name the same Servers. See ADR-0012. # # Not a Finding: it has no Severity and names no Finding. It says how things are connected, # not whether they work - "dc01 is a controller of contoso.example" is as true of a dc01 # that did not answer. A Judge returns it among its Findings, as it does its Measurements # and Timed Events, and the Run tells the four apart by type. # # The kinds are a closed list, each valid only between given kinds of things, for the # reason a Measurement's Metric is one: a misspelt kind would be a connection nobody draws, # and a Subnet linked as the controller of a Domain a picture nobody can read. # The things a Link may name, each identified by one rule (New-LinkEnd). A thing exists in # the Map by being named; there is no record per thing. $script:LinkThings = @('Machine', 'Adapter', 'Subnet', 'Server', 'Domain', 'Site', 'Namespace', 'Share', 'Service', 'Drive') # Each kind of Link, and between which things it is valid, as From>To. The Target Machine # stands at the start: where a Check knows which adapter an answer came over, the adapter # uses the server; where it does not, the Target Machine does. $script:LinkKinds = [ordered]@{ HasAdapter = @('Machine>Adapter') InSubnet = @('Adapter>Subnet') UsesGateway = @('Adapter>Server', 'Machine>Server') UsesDns = @('Adapter>Server', 'Machine>Server') UsesDhcp = @('Adapter>Server', 'Machine>Server') ControllerOf = @('Server>Domain') InSite = @('Server>Site') NamespaceOf = @('Namespace>Domain') LeadsTo = @('Namespace>Server', 'Namespace>Share', 'Drive>Namespace', 'Drive>Share') OnServer = @('Share>Server', 'Service>Server') } # Each kind of Link by the thing it starts from, as Kind:From - LeadsTo:Namespace, # LeadsTo:Drive: what a Kind declares its Judge observes ($script:<Kind>Links), and what the # coverage of a Network Map says is unknown. By the thing it starts from, because two Kinds # return one kind of Link: the Domain Kind where a Namespace leads, the Network Kind where a # drive does, and a Run without the one must not read the other's as known. $script:LinkObservations = @($script:LinkKinds.GetEnumerator() | ForEach-Object { $kind = $_.Key $_.Value | ForEach-Object { '{0}:{1}' -f $kind, ($_ -split '>')[0] } } | Select-Object -Unique) function New-Link { <# .SYNOPSIS One connection between two things a Check observed, as the Network Map holds it. Pure. .DESCRIPTION Validated here, like a Measurement: a kind not on the list, or one between things it does not join, throws where the Judge is written, and says what is allowed. The Check that observed it is stamped on by the Run, as on a Finding (Set-CheckOrigin). .PARAMETER Kind HasAdapter (Machine to Adapter), InSubnet (Adapter to Subnet), UsesGateway, UsesDns, UsesDhcp (Adapter or Machine to Server), ControllerOf (Server to Domain), InSite (Server to Site), NamespaceOf (Namespace to Domain), LeadsTo (Namespace to Server or Share, Drive to Namespace or Share), OnServer (Share or Service to Server). .PARAMETER From Where it starts, as New-LinkEnd names it; or such an end as it arrived from the Elevated Part, which is named again by the same rule. .PARAMETER To Where it leads, likewise. #> [CmdletBinding()] [OutputType([psobject])] param( [Parameter(Mandatory)][AllowEmptyString()][string]$Kind, [AllowNull()]$From, [AllowNull()]$To ) $kindValue = @($script:LinkKinds.Keys | Where-Object { $_ -eq $Kind } | Select-Object -First 1) if (-not $kindValue.Count) { throw ("'{0}' is no kind of Link. Permitted: {1}." -f $Kind, (@($script:LinkKinds.Keys) -join ', ')) } $kindValue = $kindValue[0] if ($null -eq $From) { throw ('A Link {0} needs its From: what it starts at.' -f $kindValue) } if ($null -eq $To) { throw ('A Link {0} needs its To: what it leads to.' -f $kindValue) } $start = ConvertTo-LinkEnd -End $From $end = ConvertTo-LinkEnd -End $To $pairs = $script:LinkKinds[$kindValue] if ($pairs -notcontains ('{0}>{1}' -f $start.Thing, $end.Thing)) { throw ("A Link {0} joins {1}; not {2} to {3}." -f $kindValue, (($pairs | ForEach-Object { $_ -replace '>', ' to ' }) -join ', '), $start.Thing, $end.Thing) } [pscustomobject]@{ PSTypeName = 'Gutcheck.Link' Kind = $kindValue From = $start To = $end # Stamped on by the Run, as on a Finding. See Set-CheckOrigin. CheckName = '' App = '' } } function New-LinkEnd { <# .SYNOPSIS A thing on the network as a Link names it, with the Key it is known by. Pure. .DESCRIPTION One rule per thing, so that two Checks naming the same thing name it the same: - Machine: the Target Machine, "machine". - Adapter: by its interface on the Target Machine, keyed as Windows matches the alias - in lower case, "ethernet" - and called as Windows writes it, "Ethernet" (Name). - Subnet: by its network address and prefix, "192.0.2.0/24", from any address in it. - Server: by the identity module (ConvertTo-ServerName), its name or else its address. Which Server several names and addresses are is the Map's to decide, by the same module, from every record that names one. - Domain: by its DNS name, normalised as a Server's name is. - Site: by its name within its Domain, "contoso.example/berlin". - Namespace: by its root as a UNC path, "\\contoso.example\public". - Share: by its Server and share name, "\\fs01.contoso.example\daten". - Service: by its Server and port, "fs01.contoso.example:445", or by its URL. - Drive: by where it is mapped on the Target Machine, "Z:". A thing its rule cannot name throws where the Judge is written. .PARAMETER Thing Which of the things above. .PARAMETER Name The adapter's interface alias, the Server's name, the Domain's DNS name, the site's name, a share's name beside -Server, the drive's letter. .PARAMETER Address A Server's address; for a Subnet any address in it; for a share or a Service the address of the Server it is on, where it is known. .PARAMETER PrefixLength A Subnet's prefix length. .PARAMETER Server The Server a share or a Service is on. .PARAMETER Domain The Domain a site is of. .PARAMETER Path A Namespace's path, or a share's, as a UNC path: "\\fs01\daten\Projekte" is the share daten on fs01. .PARAMETER Port A Service's port. .PARAMETER Url A Service's URL, where it is reached by one. #> [CmdletBinding()] [OutputType([psobject])] param( [Parameter(Mandatory)][AllowEmptyString()][string]$Thing, [AllowNull()][AllowEmptyString()][string]$Name = '', [AllowNull()][AllowEmptyString()][string]$Address = '', [AllowNull()]$PrefixLength, [AllowNull()][AllowEmptyString()][string]$Server = '', [AllowNull()][AllowEmptyString()][string]$Domain = '', [AllowNull()][AllowEmptyString()][string]$Path = '', [AllowNull()]$Port, [AllowNull()][AllowEmptyString()][string]$Url = '' ) $thingValue = @($script:LinkThings | Where-Object { $_ -eq $Thing } | Select-Object -First 1) if (-not $thingValue.Count) { throw ("'{0}' is no thing a Link names. Permitted: {1}." -f $Thing, ($script:LinkThings -join ', ')) } $thingValue = $thingValue[0] $end = [ordered]@{ Thing = $thingValue; Key = ''; Name = "$Name".Trim(); Address = ''; PrefixLength = $null Server = ''; Domain = ''; Port = $null; Url = '' } $serverAddress = ConvertTo-ServerAddress -Address $Address switch ($thingValue) { 'Machine' { $end.Name = '' $end.Key = 'machine' } 'Adapter' { if (-not $end.Name) { throw 'An adapter is named by its interface: the alias Windows gives it.' } # Called as Windows writes the alias, and keyed as Windows matches it: ignoring # case. Every other part compares the Key as it is. $end.Key = $end.Name.ToLowerInvariant() } 'Subnet' { $at = ConvertTo-NetworkAddress -Address $Address if ($null -eq $at) { throw ("A Subnet is named by an address in it and its prefix; '{0}' is no address." -f $Address) } $length = ConvertTo-Number $PrefixLength $bits = $at.GetAddressBytes().Length * 8 if ($null -eq $length -or $length -lt 0 -or $length -gt $bits -or $length -ne [math]::Floor($length)) { throw ("'{0}' is no prefix length for {1}: a Subnet is named by its address and prefix." -f $PrefixLength, $Address) } $network = (Get-SubnetAddress -Address $at -PrefixLength ([int]$length)).ToString().ToLowerInvariant() $end.Address = $network $end.PrefixLength = [int]$length $end.Key = '{0}/{1}' -f $network, [int]$length } 'Server' { $name = ConvertTo-ServerName -Name $end.Name # An address given as the name is the address: a DNS server is named by nothing else. if ($name -and (Test-NetworkAddress -Text $name)) { if (-not $serverAddress) { $serverAddress = ConvertTo-ServerAddress -Address $name } $name = '' } if (-not $name -and -not $serverAddress) { throw 'A Server is named by its name or its address, and this one by neither.' } $end.Name = $name $end.Address = $serverAddress $end.Key = $(if ($name) { $name } else { $serverAddress }) } 'Domain' { $name = ConvertTo-ServerName -Name $end.Name if (-not $name -or (Test-NetworkAddress -Text $name)) { throw ("A Domain is named by its DNS name; '{0}' is none." -f $Name) } $end.Name = $name $end.Key = $name } 'Site' { $domainName = ConvertTo-ServerName -Name $Domain if (-not $end.Name) { throw 'A site is named by its name within its Domain, and this one has none.' } if (-not $domainName) { throw ("The site '{0}' is named within its Domain, and none was given." -f $end.Name) } $end.Domain = $domainName $end.Key = '{0}/{1}' -f $domainName, $end.Name.ToLowerInvariant() } 'Namespace' { $root = Get-LinkUncRoot -Path $Path if (-not $root) { throw ("A Namespace is named by its root as a UNC path; '{0}' is none." -f $Path) } $end.Server = $root.Server $end.Name = $root.Share $end.Key = '\\{0}\{1}' -f $root.Server, $root.Share } 'Share' { $serverName = ConvertTo-ServerName -Name $Server $share = "$Name".Trim().ToLowerInvariant() if ($Path) { $root = Get-LinkUncRoot -Path $Path if (-not $root) { throw ("A share is named by its Server and its name; '{0}' is no UNC path." -f $Path) } $serverName = $root.Server $share = $root.Share } if (-not $serverName -or -not $share) { throw 'A share is named by its Server and its share name, and this one lacks one of them.' } $end.Server = $serverName $end.Name = $share $end.Address = $serverAddress $end.Key = '\\{0}\{1}' -f $serverName, $share } 'Service' { if ("$Url".Trim()) { $uri = $null if (-not [uri]::TryCreate("$Url".Trim(), [UriKind]::Absolute, [ref]$uri) -or -not $uri.Host) { throw ("A Service reached by a URL is named by it; '{0}' is none." -f $Url) } $end.Url = "$Url".Trim() $end.Server = ConvertTo-ServerName -Name $uri.Host $end.Address = $serverAddress $end.Key = $end.Url break } $serverName = ConvertTo-ServerName -Name $Server $number = ConvertTo-Number $Port if (-not $serverName) { throw 'A Service is named by its Server and port, or by its URL, and this one by neither.' } if ($null -eq $number -or $number -lt 1 -or $number -gt 65535 -or $number -ne [math]::Floor($number)) { throw ("A Service on {0} is named by its port; '{1}' is none." -f $serverName, $Port) } $end.Server = $serverName $end.Port = [int]$number $end.Address = $serverAddress $end.Key = '{0}:{1}' -f $serverName, [int]$number } 'Drive' { if (-not $end.Name) { throw 'A mapped drive is named by where it is mapped: its letter.' } $end.Name = $end.Name.ToUpperInvariant() $end.Key = $end.Name } } $object = [pscustomobject]$end $object.PSObject.TypeNames.Insert(0, 'Gutcheck.LinkEnd') $object } function ConvertTo-LinkEnd { <# .SYNOPSIS An end of a Link as New-LinkEnd made it; one that came from elsewhere - the Elevated Part, a test - named again by the same rule. Pure. .DESCRIPTION Named again rather than trusted: a field the other side added stays behind, and a Key the other side computed by another rule is computed again here. #> [CmdletBinding()] [OutputType([psobject])] param([Parameter(Mandatory)]$End) if ($End.PSObject.TypeNames -contains 'Gutcheck.LinkEnd') { return $End } $arguments = @{ Thing = "$(Get-DataProperty $End 'Thing')" } foreach ($field in 'Name', 'Address', 'Server', 'Domain', 'Url') { $value = "$(Get-DataProperty $End $field)" if ($value) { $arguments[$field] = $value } } foreach ($field in 'PrefixLength', 'Port') { $value = Get-DataProperty $End $field if ($null -ne $value -and "$value" -ne '') { $arguments[$field] = $value } } # A Namespace and a share are named by the path they were met by; across the Parts that # is their Key, which is that path. if ($arguments.Thing -in 'Namespace') { $arguments['Path'] = "$(Get-DataProperty $End 'Key')" } New-LinkEnd @arguments } function Select-UniqueLink { <# .SYNOPSIS Each Link once: the first of those of one kind between the same two things, in the order they came. Pure. .DESCRIPTION For a Kind whose data names one connection more than once - a namespace entry per target, a route per next hop. The things are compared by their Key, in any casing, as Windows compares an adapter's alias. Two Kinds' Links are not this to merge: the same Link from two Checks is two pieces of evidence (ADR-0012). #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyCollection()]$Link) # A hashtable compares its keys in any casing. $said = @{} foreach ($one in @($Link | Where-Object { $_ })) { $key = '{0}|{1}:{2}|{3}:{4}' -f $one.Kind, $one.From.Thing, $one.From.Key, $one.To.Thing, $one.To.Key if ($said.ContainsKey($key)) { continue } $said[$key] = $true $one } } function Get-LinkUncRoot { # The Server and the first part of a UNC path, each as the Map names it, read by the # identity of a Server (Split-UncPath); nothing for what is no UNC path or has no share. # Pure. [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyString()][string]$Path) $unc = Split-UncPath -Path $Path if (-not $unc) { return $null } $server = ConvertTo-ServerName -Name $unc.Host $share = $unc.Share.Trim().ToLowerInvariant() if (-not $server -or -not $share) { return $null } [pscustomobject]@{ Server = $server; Share = $share } } |