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 in both. $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 } 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 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) } |