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', 'App', 'WindowsService')

# 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')
    # A Windows service the Target Machine has, as the Service Kind looked at it.
    HasService   = @('Machine>WindowsService')
    # An App uses a Service, or a Windows service: added by the Run where it stamps a Check's
    # App on a Link that names one (New-CheckOutcome), never by a Judge, which does not know
    # its App.
    Uses         = @('App>Service', 'App>WindowsService')
}

# The kinds of Link the Run adds itself, by Kind:From, each with the Kind:From whose Links it
# adds them to: known where that one is (Get-NetworkMapCoverageView).
$script:LinkObservationsOfTheRun = @{ 'Uses:App' = 'OnServer:Service' }

# 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), HasService
        (Machine to WindowsService), Uses (App to Service or WindowsService, added by the Run).
    .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:".
        - App: by its name as its Check Definition gives it, "Outlook".
        - WindowsService: a Windows service on the Target Machine, by its service name, keyed
          as Windows matches it - in lower case, "spooler" - and called as Windows writes it.
        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; for a Service what its
        Check Definition calls it, which its Key leaves out.
    .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
        }
        'App' {
            if (-not $end.Name) { throw 'An App is named by its name, as its Check Definition gives it.' }
            $end.Key = $end.Name
        }
        'WindowsService' {
            if (-not $end.Name) { throw 'A Windows service is named by its service name, as Windows gives it.' }
            $end.Key = $end.Name.ToLowerInvariant()
        }
    }

    $object = [pscustomobject]$end
    $object.PSObject.TypeNames.Insert(0, 'Gutcheck.LinkEnd')
    $object
}

function New-ServiceLink {
    <#
    .SYNOPSIS
        A Service on the Server it lies on: by its Server and port, or by its URL, at the
        address it was reached at. Nothing for what its rule cannot name. Pure.
    .DESCRIPTION
        What a Kind that reaches an application's Servers returns, so that the Network Map
        joins the App to them once the Run stamps it on (New-CheckAppLink, ADR-0012). A
        host a Check was given that no rule can name costs one Link, never the Judge.
    .PARAMETER Address
        Where it answered; empty where it did not, or where a proxy answered for it.
    .PARAMETER Name
        What the Service is called, where the Check Definition names its port: "SwyxServer-
        Client-API". Never part of its Key: two Checks that call one port differently name
        one Service.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()][AllowEmptyString()][string]$Server = '',
        [AllowNull()]$Port,
        [AllowNull()][AllowEmptyString()][string]$Url = '',
        [AllowNull()][AllowEmptyString()][string]$Address = '',
        [AllowNull()][AllowEmptyString()][string]$Name = ''
    )

    try {
        $service = if ("$Url".Trim()) { New-LinkEnd -Thing Service -Url $Url -Address $Address -Name $Name }
                   else { New-LinkEnd -Thing Service -Server $Server -Port $Port -Address $Address -Name $Name }
        New-Link -Kind OnServer -From $service -To (New-LinkEnd -Thing Server -Name $service.Server -Address $Address)
    }
    catch { return }
}

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