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)
}