Private/ServerIdentity.ps1
|
# The identity of a Server. # # Which Server a name or an address is, decided once for every Check and view (#145): the # Kinds when they name and resolve what they looked at, the Finding when it checks its # Subjects, the Leads and References when they compare Subjects, the Report when it merges # its nodes. A rule of its own in any of them is a Server drawn twice, or two drawn as one. # # Pure functions, all of them: nothing here resolves a name or asks Windows anything. What # a name resolved to is handed in by the Gatherer that resolved it. # The addresses that are never on the internet: RFC 1918, the shared space of RFC 6598, # link-local, loopback, and for IPv6 unique local, link-local, loopback and the site-local # addresses Windows puts in as DNS servers where nobody gave IPv6 any (fec0:0:0:ffff::1 to 3, # of a range long withdrawn, and never routed on the internet). The IPv4 ones # as their bytes: written as addresses, they are what the package check refuses to find in # the code - an address out of somebody's own network. # # The one list: the Zone of a Measurement is decided from it (Get-MeasurementZone), and so # is whether a Server is in the internet. The ranges kept for documentation and benchmarks # are not in it, and count as public for the Zone. In the internet the ranges kept for # documentation are not: see Test-DocumentationAddress. $script:PrivateAddressRanges = @( @([byte[]](10, 0, 0, 0), 8), @([byte[]](172, 16, 0, 0), 12), @([byte[]](192, 168, 0, 0), 16), @([byte[]](100, 64, 0, 0), 10), @([byte[]](169, 254, 0, 0), 16), @([byte[]](127, 0, 0, 0), 8), @('fc00::', 7), @('fe80::', 10), @('fec0::', 10), @('::1', 128) ) function ConvertTo-ServerName { <# .SYNOPSIS A Server's name as every Check and view names it: trimmed, the host of a UNC path, without a port, without a trailing dot, in lower case. Pure. .DESCRIPTION An address stays the address it is, an IPv6 one with all its colons: only brackets around it and a port behind them go, and its hexadecimal digits are written in lower case. A port is only ever taken from a name or an IPv4 address, where one colon stands before digits. Nothing for nothing. .PARAMETER Name The name or address as a Check met it: typed, logged, mapped, resolved. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyString()][string]$Name) $text = "$Name".Trim() # \\fs01\daten names the Server fs01. $unc = Split-UncPath -Path $text if ($unc) { $text = $unc.Host } # [2001:db8::10]:443 before anything else: .NET reads it as an address, port and all. if ($text -match '^\[(?<host>[^\]]+)\](:\d+)?$') { $text = $Matches.host.Trim() } if (Test-NetworkAddress -Text $text) { return $text.ToLowerInvariant() } if ($text -match '^(?<host>[^:]+):\d+$') { $text = $Matches.host } $text.Trim().TrimEnd('.').ToLowerInvariant() } function Split-UncPath { <# .SYNOPSIS The host, the share and the path below it that a UNC path names, each as it is written; nothing for what is no UNC path. Pure. .DESCRIPTION \\fs01\daten\Projekte, and //fs01/daten/Projekte as some logs write it: the host is the first part, the share the second, the rest the path below, joined by backslashes. A part that is not there is empty: \\fs01 is a host without a share. Which Server the host is, is ConvertTo-ServerName's to say; a WebDAV path keeps its transport in the host, \\fs01@SSL, for the caller that reads it. .PARAMETER Path The path as a Check met it. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyString()][string]$Path) $text = "$Path".Trim() if ($text -notmatch '^[\\/]{2}') { return $null } $parts = @($text.TrimStart('\', '/') -split '[\\/]+') [pscustomobject]@{ Host = $parts[0] Share = $(if ($parts.Count -gt 1) { $parts[1] } else { '' }) Path = (@($parts | Select-Object -Skip 2 | Where-Object { $_ }) -join '\') } } function Test-SameServer { <# .SYNOPSIS Whether two names are one Server. Pure. .DESCRIPTION Each is first normalised by ConvertTo-ServerName. They are one Server where they are then the same, or where one is a short name and the other a FQDN whose first label it is: FS01 and fs01.contoso.example. Two different FQDNs are never one, though they share a first label: fs01.a.example and fs01.b.example are two machines in two domains. An address is only itself; which name it has is not for this to know. #> [CmdletBinding()] [OutputType([bool])] param([AllowNull()][AllowEmptyString()][string]$Name, [AllowNull()][AllowEmptyString()][string]$Other) $one = ConvertTo-ServerName -Name $Name $two = ConvertTo-ServerName -Name $Other if (-not $one -or -not $two) { return $false } if ($one -eq $two) { return $true } if ((Test-NetworkAddress -Text $one) -or (Test-NetworkAddress -Text $two)) { return $false } if ($one.Contains('.') -eq $two.Contains('.')) { return $false } $short = $(if ($one.Contains('.')) { $two } else { $one }) $full = $(if ($one.Contains('.')) { $one } else { $two }) ($full -split '\.')[0] -eq $short } function Get-ServerKey { <# .SYNOPSIS The key a Kind groups a Server by, among the names it met: the name as ConvertTo-ServerName writes it, and for a short name the one FQDN among the others that it begins. Pure. .DESCRIPTION By the rule of Test-SameServer, so that a Kind never hands a Finding two Subjects that are one Server: FS01 among fs01.contoso.example is fs01.contoso.example. A short name that begins none of them, or two - fs01 among fs01.a.example and fs01.b.example - stays itself, and so do an FQDN and an address: the relation does not chain, and two FQDNs are never joined through a third name. .PARAMETER Name The name or address to key. .PARAMETER Among Every name the Kind met, in any spelling, this one among them or not. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyString()][string]$Name, [AllowNull()][AllowEmptyCollection()][string[]]$Among) $key = ConvertTo-ServerName -Name $Name if (-not $key -or $key.Contains('.') -or (Test-NetworkAddress -Text $key)) { return $key } # Only what contains the name can be one Server with it; the rest is not asked. A Run # keys every Finding's Subject among all the others. Of a short name, the one Server # that is not the name itself is a FQDN it begins. $full = @(@($Among) | Where-Object { $_ -and $_.IndexOf($key, [System.StringComparison]::OrdinalIgnoreCase) -ge 0 } | ForEach-Object { ConvertTo-ServerName -Name $_ } | Where-Object { $_ -and $_ -ne $key -and (Test-SameServer -Name $key -Other $_) } | Select-Object -Unique) if ($full.Count -eq 1) { return $full[0] } $key } # The Service a Finding's Subject may name beside its Server (#166, ADR-0015): written as the # Network Map keys a Service (Get-NetworkMapServiceKey), "fs01.contoso.example:445" or an # absolute URL. Any other Subject - a Server, a program, a drive - names no Service. function Split-ServiceSubject { <# .SYNOPSIS The Server and the port, or the URL, a Subject names a Service by; nothing for a Subject that names no Service. Pure. .DESCRIPTION A URL is an absolute one with a host, "https://cloud.contoso.example/", as it is written: Url, and no Server or port. A port is the digits behind the one colon of a name or an IPv4 address, or behind the brackets of an IPv6 one, from 1 to 65535: Server as written, Port as a number, and no Url. "Z:", a UNC path and an address alone name none. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyString()][string]$Name) $text = "$Name".Trim() if (-not $text) { return $null } if (Test-ServiceUrl -Text $text) { return [pscustomobject]@{ Server = ''; Port = $null; Url = $text } } if ($text -notmatch '^\[(?<host>[^\]]+)\]:(?<port>\d{1,5})$' -and $text -notmatch '^(?<host>[^:\\/\[\]]+):(?<port>\d{1,5})$') { return $null } $port = [int]$Matches.port if ($port -lt 1 -or $port -gt 65535) { return $null } [pscustomobject]@{ Server = $Matches.host.Trim(); Port = $port; Url = '' } } function Test-ServiceUrl { <# .SYNOPSIS Whether a text is a URL a Service is named by: absolute, written scheme://, with a host. Pure. .DESCRIPTION The one reading, for a Subject (Split-ServiceSubject) and for a record on the Map (Get-NetworkMapServiceKey): a share path would pass .NET for a file URL, and is a record of the SMB Service it names a port of. #> [CmdletBinding()] [OutputType([bool])] param([AllowNull()][AllowEmptyString()][string]$Text) $uri = $null [bool]($Text -match '^[a-z][a-z0-9+.-]*://' -and [uri]::TryCreate($Text, [UriKind]::Absolute, [ref]$uri) -and $uri.Host) } function Format-ServiceSubject { <# .SYNOPSIS A Service on a port as a Finding's Subject names it: the Server as written and the port, an IPv6 address in brackets, so that Split-ServiceSubject reads it back. Nothing for no Server. Pure. ADR-0015. .DESCRIPTION The one way every Kind writes a Service Subject. Which Server it is, is for the key to say (Get-SubjectKey); the Network Map writes its own key without brackets. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyString()][string]$Server, [Parameter(Mandatory)][int]$Port) $name = "$Server".Trim() if (-not $name) { return '' } if ($name.Contains(':') -and -not $name.StartsWith('[')) { $name = '[{0}]' -f $name } '{0}:{1}' -f $name, $Port } function ConvertTo-UrlService { <# .SYNOPSIS The Service on a port a URL is on: its host as every Check names a Server, its port - the scheme's own where the URL names none - and the Subject both make, with the URL as it was given. Nothing for what is no URL (Test-ServiceUrl), or one of a scheme without a port. Pure. #173. .DESCRIPTION A log names the full URL of a request that failed, https://mail.contoso.example/OAB/... What failed is the Service the Server Check and the Links already draw, mail.contoso.example:443, and not a URL Service of its own: every path on a host would be one. The URL itself belongs in the words of what is said of it. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyString()][string]$Url) $text = "$Url".Trim() if (-not (Test-ServiceUrl -Text $text)) { return $null } $uri = [uri]$text if ($uri.Port -lt 1) { return $null } New-PortService -Server (ConvertTo-ServerName -Name $uri.Host) -Port $uri.Port -Url $text } function New-PortService { <# .SYNOPSIS A Service on a port as ConvertTo-UrlService gives one: Server, Port, the Subject both make, and the URL it was read from, if any. A Server without a port is the Server alone, its Subject its name. Nothing for no Server. Pure. .DESCRIPTION For what names a host and maybe a port without a URL, "Host cloud.contoso.example not found": it is about the same Service, and comes in the same shape. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyString()][string]$Server, [AllowNull()]$Port, [AllowNull()][AllowEmptyString()][string]$Url) $name = "$Server".Trim() if (-not $name) { return $null } $number = ConvertTo-Number $Port if ($null -eq $number -or $number -lt 1 -or $number -gt 65535 -or $number -ne [math]::Floor($number)) { $number = $null } else { $number = [int]$number } $subject = $name if ($null -ne $number) { $subject = Format-ServiceSubject -Server $name -Port $number } [pscustomobject]@{ Server = $name; Port = $number; Subject = $subject; Url = "$Url".Trim() } } # The Namespace a Finding's Subject may name (#172): written as the Network Map keys a # Namespace (New-LinkEnd -Thing Namespace), by its root as a UNC path, # "\\contoso.example\public". A part of its own, never the Server the root is on: no Kind # names a Server by a UNC path in a Finding, and a Namespace is judged by its folders. function Split-NamespaceSubject { <# .SYNOPSIS The Server, the share and the key a Subject names a Namespace by; nothing for a Subject that names none. Pure. .DESCRIPTION A UNC path of a host and a share and nothing below them, \\contoso.example\public, or //contoso.example/public: Server as ConvertTo-ServerName writes the host, Share in lower case, and Key as the Network Map keys the Namespace, \\server\share. A host alone, \\fs01, and a path below a share name none: the one is its Server, the other no root. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyString()][string]$Name) $unc = Split-UncPath -Path $Name if (-not $unc -or $unc.Path) { 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; Key = ('\\{0}\{1}' -f $server, $share) } } function Split-Subject { <# .SYNOPSIS What kind of thing a Finding's Subject names - a Namespace, a Service or a Server - and the pieces it names it by; nothing for no Subject. Pure. .DESCRIPTION The one place the question is asked, for every Check and view that tells the three apart: a rule of its own anywhere would take a Namespace for its host's Server again. A Namespace first, as Split-NamespaceSubject reads it: Server, Share and Key. Then a Service, as Split-ServiceSubject reads it: Server and Port, or Url. Anything else is Kind Server, its name as written: a Server, or a program or a drive, which is keyed as a Server would be (Get-ServerKey). Each object carries every piece, empty where its Kind has none. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyString()][string]$Name) $text = "$Name".Trim() if (-not $text) { return $null } $namespace = Split-NamespaceSubject -Name $text if ($namespace) { return [pscustomobject]@{ Kind = 'Namespace'; Server = $namespace.Server; Share = $namespace.Share; Key = $namespace.Key; Port = $null; Url = '' } } $service = Split-ServiceSubject -Name $text if ($service) { return [pscustomobject]@{ Kind = 'Service'; Server = $service.Server; Share = ''; Key = ''; Port = $service.Port; Url = $service.Url } } [pscustomobject]@{ Kind = 'Server'; Server = $text; Share = ''; Key = ''; Port = $null; Url = '' } } function Get-SubjectKey { <# .SYNOPSIS The key a Finding's Subject is one with another by: a Server's as Get-ServerKey gives it, a Service's as the Network Map writes it. Pure. .DESCRIPTION A URL keys to itself. A Service on a port keys its Server by Get-ServerKey among -Among and keeps the port: FS01:445 among fs01.contoso.example is fs01.contoso.example:445. A Namespace keys as the Network Map keys it, \\contoso.example\public. Any other Subject is keyed exactly as Get-ServerKey keys it, so a Server Subject keys as it always has. .PARAMETER Name The Subject to key. .PARAMETER Among Every name the Kind or the Run met, Services among them: a Service's Server counts as the Server it is on. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyString()][string]$Name, [AllowNull()][AllowEmptyCollection()][string[]]$Among) $subject = Split-Subject -Name $Name if (-not $subject -or $subject.Kind -eq 'Server') { return (Get-ServerKey -Name $Name -Among $Among) } if ($subject.Kind -eq 'Namespace') { return $subject.Key } if ($subject.Url) { return $subject.Url } Get-NetworkMapServiceKey -ServerKey (Get-ServerKey -Name $subject.Server -Among $Among) -Port $subject.Port } function Test-SameSubject { <# .SYNOPSIS Whether two Subjects of a Finding are one: one Server, one Service, or one Namespace. Pure. .DESCRIPTION Two Servers by Test-SameServer. Two Services on a port where the port is the same and their Servers are one by Test-SameServer; two URLs where they are the same, ignoring case. Two Namespaces where the share is the same and the Servers are one. A Server and a Service on it are two, and so are a URL and a port on its host, and a Namespace and the Server its root is on: each is a Subject of its own. #> [CmdletBinding()] [OutputType([bool])] param([AllowNull()][AllowEmptyString()][string]$Name, [AllowNull()][AllowEmptyString()][string]$Other) $one = Split-Subject -Name $Name $two = Split-Subject -Name $Other if (-not $one -or -not $two -or $one.Kind -ne $two.Kind) { return $false } switch ($one.Kind) { 'Namespace' { return ($one.Share -eq $two.Share -and (Test-SameServer -Name $one.Server -Other $two.Server)) } 'Server' { return (Test-SameServer -Name $Name -Other $Other) } } if ($one.Url -or $two.Url) { return [bool]($one.Url -and $two.Url -and $one.Url -eq $two.Url) } $one.Port -eq $two.Port -and (Test-SameServer -Name $one.Server -Other $two.Server) } function Remove-AddressScope { <# .SYNOPSIS An address without the zone of a link-local one: fe80::1%4 is fe80::1. Pure. .DESCRIPTION The zone is this machine's own name for one of its adapters: the same Server reached through the dock and through Wi-Fi would otherwise be two. A name stays as it is - a percent sign behind anything but an address is no zone. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyString()][string]$Text) $value = "$Text".Trim() if ($value.Contains('%') -and (Test-NetworkAddress -Text $value)) { return ($value -split '%')[0] } $value } function Select-ServerAddress { <# .SYNOPSIS The one address a Server is shown at, from what its name resolved to. Pure. .DESCRIPTION IPv4 before IPv6, because it is the address the Customer's network uses and the one a Technician pings; within a family the first in the order resolution returned them, never the first as text, which would put 192.0.2.10 before 192.0.2.9. Nothing where nothing resolved. The other addresses are the caller's to keep, where it says them. .PARAMETER Address What the name resolved to, in the order it came: addresses as text or as .NET gives them, or records that carry theirs as IPAddress. What is no address is passed over. .PARAMETER All Every address of the family the one address is of, in the order they came, once each: a name several Servers answer to - a domain's - leads to each of them. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyCollection()]$Address, [switch]$All) $first = '' $read = [System.Collections.Generic.List[psobject]]::new() foreach ($answer in @($Address)) { if ($null -eq $answer) { continue } $text = $(if ($answer -is [string] -or $answer -is [System.Net.IPAddress]) { "$answer" } else { "$(Get-DataProperty $answer 'IPAddress')" }) $text = $text.Trim() $parsed = ConvertTo-NetworkAddress -Address $text if ($null -eq $parsed) { continue } $isV4 = $parsed.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetwork if ($isV4 -and -not $All) { return $text } if (-not $first) { $first = $text } $read.Add([pscustomobject]@{ Text = $text; IsV4 = $isV4 }) } if (-not $All) { return $first } $v4 = @($read | Where-Object IsV4) @($(if ($v4.Count) { $v4 } else { $read }) | ForEach-Object { $_.Text } | Select-Object -Unique) } function Test-NetworkAddress { <# .SYNOPSIS Whether a text is an address and not a name: IPv4 as four numbers, or IPv6, with or without the zone of a link-local one. Pure. .DESCRIPTION By the one reading of an address (ConvertTo-NetworkAddress): a lone number is none, and nor is a name with a port. #> [CmdletBinding()] [OutputType([bool])] param([AllowNull()][AllowEmptyString()][string]$Text) $null -ne (ConvertTo-NetworkAddress -Address $Text) } function Test-PrivateAddress { <# .SYNOPSIS Whether an address is one that is never on the internet, by the one list of private ranges the Zone of a Measurement is decided from. Pure. .DESCRIPTION False for a name and for nothing: only an address can be private. .PARAMETER Address The address as text, or as .NET gives it. #> [CmdletBinding()] [OutputType([bool])] param([AllowNull()]$Address) $parsed = $(if ($Address -is [System.Net.IPAddress]) { $Address } else { ConvertTo-NetworkAddress -Address "$Address" }) if ($null -eq $parsed) { return $false } foreach ($range in $script:PrivateAddressRanges) { if (Test-AddressInPrefix -Address $parsed -Network $range[0] -PrefixLength $range[1]) { return $true } } $false } # The ranges RFC 5737 and RFC 3849 keep for documentation: TEST-NET-1 to 3 and 2001:db8::/32. # No such address is routed, on the internet or anywhere else. $script:DocumentationAddressRanges = @( @('192.0.2.0', 24), @('198.51.100.0', 24), @('203.0.113.0', 24), @('2001:db8::', 32) ) function Test-DocumentationAddress { <# .SYNOPSIS Whether an address is one kept for documentation, which no network on the internet uses: 192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24, 2001:db8::/32. Pure. .DESCRIPTION False for a name and for nothing. Whether such an address is a test network of the machine's own is for the caller to say, by the machine's own subnets. .PARAMETER Address The address as text, or as .NET gives it. #> [CmdletBinding()] [OutputType([bool])] param([AllowNull()]$Address) $parsed = $(if ($Address -is [System.Net.IPAddress]) { $Address } else { ConvertTo-NetworkAddress -Address "$Address" }) if ($null -eq $parsed) { return $false } foreach ($range in $script:DocumentationAddressRanges) { if (Test-AddressInPrefix -Address $parsed -Network $range[0] -PrefixLength $range[1]) { return $true } } $false } # The one reading of an address, for every Check and view: the Zone of a Measurement, the # ends of a Link and the Network Map read an address here, and nowhere else. function ConvertTo-NetworkAddress { # An address as .NET reads it, without the zone of a link-local one; $null for a name, # for nothing, and for a number that is no dotted address. Pure. [CmdletBinding()] [OutputType([System.Net.IPAddress])] param([AllowNull()][AllowEmptyString()][string]$Address) $text = ("$Address".Trim() -split '%')[0] if (-not $text) { return $null } # "1" parses as 0.0.0.1, which nobody measured. if ($text -notmatch ':' -and $text -notmatch '^\d{1,3}(\.\d{1,3}){3}$') { return $null } $parsed = $null if ([System.Net.IPAddress]::TryParse($text, [ref]$parsed)) { return $parsed } $null } function ConvertTo-ServerAddress { # An address as the Map writes it - as .NET writes it, in lower case, without the zone # of a link-local one - or nothing for what is no address. Pure. [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyString()][string]$Address) $parsed = ConvertTo-NetworkAddress -Address $Address if ($null -eq $parsed) { return '' } $parsed.ToString().ToLowerInvariant() } function Test-AddressInPrefix { # Whether an address lies in a network given by an address - as text, as .NET gives it, # or as its bytes - and a prefix length. Pure. [CmdletBinding()] [OutputType([bool])] param([Parameter(Mandatory)][System.Net.IPAddress]$Address, [AllowNull()]$Network, [AllowNull()]$PrefixLength) $net = $Network if ($net -is [byte[]]) { $net = [System.Net.IPAddress]::new($net) } elseif ($net -isnot [System.Net.IPAddress]) { $net = ConvertTo-NetworkAddress -Address "$Network" } $length = ConvertTo-Number $PrefixLength if ($null -eq $net -or $null -eq $length -or $net.AddressFamily -ne $Address.AddressFamily) { return $false } $a = $Address.GetAddressBytes() $b = $net.GetAddressBytes() $bits = [int][math]::Min($length, $a.Length * 8) for ($i = 0; $i -lt $bits; $i++) { $mask = 0x80 -shr ($i % 8) if (($a[[int][math]::Floor($i / 8)] -band $mask) -ne ($b[[int][math]::Floor($i / 8)] -band $mask)) { return $false } } $true } function Get-SubnetAddress { # The network an address lies in under a prefix length: the address with every bit past # the prefix cleared. 192.0.2.17 and 24 are 192.0.2.0. Pure. [CmdletBinding()] [OutputType([System.Net.IPAddress])] param([Parameter(Mandatory)][System.Net.IPAddress]$Address, [Parameter(Mandatory)][int]$PrefixLength) $bytes = $Address.GetAddressBytes() for ($i = 0; $i -lt $bytes.Length; $i++) { $keep = [math]::Max(0, [math]::Min(8, $PrefixLength - $i * 8)) $bytes[$i] = $bytes[$i] -band ((0xFF -shl (8 - $keep)) -band 0xFF) } [System.Net.IPAddress]::new($bytes) } |