Private/Kinds/Domain.ps1

# The Domain Kind: the domain this machine belongs to, and each of its domain controllers.
#
# A machine of a domain talks to "the domain", and that is whichever domain controller
# answers first: for the sign-in, for Group Policy, for a drive mapped by the domain's
# name. With two controllers and one of them slow, every second morning is slow, and
# nothing on the machine is wrong. A Report that measures "the domain" measures the one
# that answered the Run.
#
# So this Kind asks DNS which controllers the domain has - the record every member finds
# them by, _ldap._tcp.dc._msdcs.<domain> - and measures each one by itself: whether the
# ports a member needs answer (LDAP 389, Kerberos 88, SMB 445), how far its clock is from
# this one, and whether it has the shares a member takes from any of them. Nothing needs
# admin rights, and nothing is changed: no secure channel is tested or reset.
#
# The shares: SYSVOL, which Group Policy is read from, on every controller; the first
# folder of every drive the User has mapped by the domain's name (\\contoso.example\public
# is "public"); and the mapped path itself (public\Home). A share that is there on one
# controller and not on another is what makes a drive come up at once on some days and
# after a minute and a half on others - and so is one that one controller takes twenty
# seconds to answer for. The first folder alone did not show it on the machine this was
# written for: both controllers had it, and the sign-in was slow by one of them.
#
# The paths are those of the User's drives, which the Report lists anyway. Nothing in
# them is read: the question is whether the folder is there.
#
# Asking for a share that does not exist takes Windows some twenty seconds, whatever the
# server (measured: 21 s against a controller that answered an existing one in 12 ms). So
# the shares are asked for at the same time, each for no longer than a limit, and what a
# question took is kept and never judged.
#
# Two things a controller hands out, and that were wrong on the machine this was written
# for while each controller answered every port and had every share:
#
# The namespace. \\contoso.example\public\Daten is no folder on a controller: it is an
# entry of a DFS namespace that names the Servers the folder really is on. An entry that
# still names a Server which is gone sends a client there first, and the client waits.
# Windows tells any member which Servers stand behind a path (NetDfsGetInfo); each of
# them is asked whether its name resolves and whether it takes file connections.
#
# The policies. Every controller has its own copy of SYSVOL, and they are kept the same
# by replication. Where that stopped, one controller hands out last month's drive
# mappings and the other this month's. Each policy's version is one line in a file of
# its folder, readable by any member; they are read from every controller and compared.
#
# Nothing of either needs admin rights, and nothing is read out of a policy but its version.
#
# What the Gatherer returns:
# Joined, Name, Site, LogonServer
# Addresses what the domain's own name resolves to
# Roots the first folders of the drives mapped by the domain's name
# Paths those drives' paths below the domain's name, where they go deeper
# Asked whether DNS was asked for the controllers
# Controllers { Name, Addresses, LdapMs, KerberosMs, SmbMs, OffsetSeconds,
# Shares = { Share, Exists, Ms, TimedOut },
# Policies = { Id, Version }, or $null where they were not read }
# Namespace { Path, Targets = { Server, Share, Online, Addresses, SmbMs } }, one for
# each namespace entry a mapped drive goes through
# PolicyNames { Id, Name }, as far as the result of policy named them
# Prefixes the machine's own addresses with their prefix lengths (Get-OwnPrefix)
# Routes the route to the first address of each controller and each Server behind
# the namespace (Get-AddressRoute): what the Judge decides the Zone from

# How many controllers are measured, and how many mapped folders asked for: a domain with
# forty controllers is not this Run's to walk through.
$script:DomainMaximumControllers = 6
$script:DomainMaximumRoots       = 3
$script:DomainMaximumPaths       = 6

# The ports a member needs of a controller, as the Gatherer connects to them.
$script:DomainLdapPort     = 389
$script:DomainKerberosPort = 88
$script:DomainSmbPort      = 445
# And the one its clock is asked on, as Get-SntpOffset asks it: SNTP.
$script:DomainSntpPort     = 123

# How long a question for a share may take. See the head of this file.
$script:DomainShareTimeoutSeconds = 25

# How long reading the versions of a controller's policies may take.
$script:DomainPolicyTimeoutSeconds = 20
# How many entries of the namespace are followed.
$script:DomainMaximumEntries = 10

# The kinds of Link the Judge returns, by the thing each starts from ($script:LinkObservations):
# what a Run without this Check does not know of the Domain. Not InSite: the Gatherer knows
# the site of this machine, never a controller's, and a Run with this Check would read their
# sites as none. See ConvertTo-DomainLink.
$script:DomainLinks = @('ControllerOf:Server', 'NamespaceOf:Namespace', 'LeadsTo:Namespace', 'OnServer:Share')

# What Windows is asked for the Servers behind a path of a namespace. Lines of a list and
# not a here-string: the definition is C#, and it is short.
$script:DomainDfsDefinition = @(
    '[DllImport("netapi32.dll", CharSet = CharSet.Unicode)]'
    'public static extern int NetDfsGetInfo(string DfsEntryPath, string ServerName, string ShareName, int Level, out IntPtr Buffer);'
    '[DllImport("netapi32.dll")]'
    'public static extern int NetApiBufferFree(IntPtr Buffer);'
    '[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]'
    'public struct DFS_INFO_3 { public string EntryPath; public string Comment; public uint State; public uint NumberOfStorages; public IntPtr Storage; }'
    '[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]'
    'public struct DFS_STORAGE_INFO { public uint State; public string ServerName; public string ShareName; }'
) -join "`n"

function Get-DomainMappedRoot {
    <#
    .SYNOPSIS
        The first folders of the paths that go by the domain's name. Pure.
    .DESCRIPTION
        \\contoso.example\public\Home is "public", and so is \\CONTOSO\public where
        CONTOSO is the domain's short name. A path to a server is none. Of a path only
        this first folder is kept: it is the share, or the namespace, and names nobody's
        work (ADR-0006).
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyCollection()][string[]]$Path, [AllowNull()][AllowEmptyString()][string]$Domain)

    $full  = ConvertTo-ServerName -Name $Domain
    if (-not $full) { return }
    $short = ($full -split '\.')[0]
    $found = [System.Collections.Generic.List[string]]::new()
    foreach ($entry in @($Path | Where-Object { $_ })) {
        $unc = Split-UncPath -Path $entry
        if (-not $unc -or -not $unc.Share) { continue }
        $server = ConvertTo-ServerName -Name $unc.Host
        $root   = $unc.Share
        if ($server -ne $full -and $server -ne $short) { continue }
        if (-not @($found | Where-Object { $_ -eq $root }).Count) { $found.Add($root) }
    }
    @($found | Sort-Object)
}

function Get-DomainMappedPath {
    <#
    .SYNOPSIS
        The paths that go by the domain's name and deeper than their first folder, as
        they stand below the name: \\contoso.example\public\Home is "public\Home". Pure.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyCollection()][string[]]$Path, [AllowNull()][AllowEmptyString()][string]$Domain)

    $full  = ConvertTo-ServerName -Name $Domain
    if (-not $full) { return }
    $short = ($full -split '\.')[0]
    $found = [System.Collections.Generic.List[string]]::new()
    foreach ($entry in @($Path | Where-Object { $_ })) {
        $unc = Split-UncPath -Path $entry
        if (-not $unc -or -not $unc.Share -or -not $unc.Path) { continue }
        $server = ConvertTo-ServerName -Name $unc.Host
        $below  = '{0}\{1}' -f $unc.Share, $unc.Path
        if ($server -ne $full -and $server -ne $short) { continue }
        if (-not @($found | Where-Object { $_ -eq $below }).Count) { $found.Add($below) }
    }
    @($found | Sort-Object)
}

function Resolve-DomainAddress {
    <#
    .SYNOPSIS
        The IPv4 addresses a name resolves to, or none. Never throws.
    .DESCRIPTION
        In the order DNS answered them, which is the order Select-ServerAddress picks
        the one a Server is shown at from: never sorted as text, which would put
        192.0.2.10 before 192.0.2.9.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyString()][string]$Name)

    if (-not "$Name".Trim()) { return }
    try {
        [System.Net.Dns]::GetHostAddresses($Name) | Where-Object { "$($_.AddressFamily)" -eq 'InterNetwork' } |
            ForEach-Object { "$_" } | Select-Object -Unique
    }
    catch { }
}

function Test-DomainShare {
    <#
    .SYNOPSIS
        Whether each of a list of shares exists, all asked at once and none for longer
        than the limit. Reads nothing of what is in them. Never throws.
    .DESCRIPTION
        Each in a runspace of its own: the question blocks until Windows has given up,
        and one after the other four of them would be a minute and a half. One that has
        not answered when the limit is up is left behind and said to have timed out.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][AllowEmptyCollection()][string[]]$Path, [int]$TimeoutSeconds = $script:DomainShareTimeoutSeconds)

    $asked = @(foreach ($entry in @($Path | Where-Object { $_ })) {
        $shell = $null
        $handle = $null
        try {
            $shell  = [powershell]::Create()
            $null   = $shell.AddScript('param($p) $w = [Diagnostics.Stopwatch]::StartNew(); $e = [IO.Directory]::Exists($p); [pscustomobject]@{ Exists = $e; Ms = $w.ElapsedMilliseconds }').AddArgument($entry)
            $handle = $shell.BeginInvoke()
        }
        catch { $handle = $null }
        [pscustomobject]@{ Path = $entry; Shell = $shell; Handle = $handle }
    })

    $until = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds)
    foreach ($one in $asked) {
        $exists = $null; $ms = $null; $timedOut = $true
        if ($one.Handle) {
            $left = [int][math]::Max(0, ($until - [DateTime]::UtcNow).TotalMilliseconds)
            try {
                if ($one.Handle.AsyncWaitHandle.WaitOne($left)) {
                    $answer = @($one.Shell.EndInvoke($one.Handle))[0]
                    $exists = [bool]$answer.Exists; $ms = [int]$answer.Ms; $timedOut = $false
                }
            }
            catch { }
        }
        # Not waited for: a question Windows has not given up on yet would hold the Run.
        if ($one.Shell -and -not $timedOut) { try { $one.Shell.Dispose() } catch { } }
        [pscustomobject]@{ Path = $one.Path; Exists = $exists; Ms = $ms; TimedOut = $timedOut }
    }
}

function Get-DomainNamespaceTarget {
    <#
    .SYNOPSIS
        The Servers a path of a DFS namespace leads to, as Windows tells a member: each
        with its share and whether the namespace has it switched on. Nothing where the
        path is no entry of a namespace. Never throws.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([Parameter(Mandatory)][string]$Path)

    try {
        if (-not ('Gutcheck.DfsNative' -as [type])) {
            Add-Type -Namespace Gutcheck -Name DfsNative -MemberDefinition $script:DomainDfsDefinition
        }
        $buffer = [IntPtr]::Zero
        if ([Gutcheck.DfsNative]::NetDfsGetInfo($Path, $null, $null, 3, [ref]$buffer) -ne 0) { return }
        try {
            $info = [Runtime.InteropServices.Marshal]::PtrToStructure($buffer, [type][Gutcheck.DfsNative+DFS_INFO_3])
            $size = [Runtime.InteropServices.Marshal]::SizeOf([type][Gutcheck.DfsNative+DFS_STORAGE_INFO])
            for ($i = 0; $i -lt [int]$info.NumberOfStorages; $i++) {
                $one = [Runtime.InteropServices.Marshal]::PtrToStructure([IntPtr]($info.Storage.ToInt64() + $i * $size), [type][Gutcheck.DfsNative+DFS_STORAGE_INFO])
                # 2 is "online" in the namespace: a target that is switched off there is
                # not handed to a client.
                [pscustomobject]@{ Server = "$($one.ServerName)".Trim(); Share = "$($one.ShareName)".Trim(); Online = [bool]($one.State -band 2) }
            }
        }
        finally { [void][Gutcheck.DfsNative]::NetApiBufferFree($buffer) }
    }
    catch { }
}

function Get-DomainNamespaceEntry {
    <#
    .SYNOPSIS
        The entries of a DFS namespace that the drives mapped by the domain's name go
        through: the root, and for each drive the first folder up from its path that is
        an entry. Decides nothing.
    .DESCRIPTION
        \\contoso.example\public\Daten\max is no entry; \\contoso.example\public\Daten
        is. So a path is walked up until Windows knows it, and no further than its root.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][AllowEmptyCollection()][string[]]$Root, [AllowNull()][AllowEmptyCollection()][string[]]$Path,
          [AllowNull()][AllowEmptyString()][string]$Domain)

    $name = "$Domain".Trim().TrimEnd('.')
    if (-not $name) { return }
    $seen = @{}
    $candidates = @(@($Root | Where-Object { $_ }) + @($Path | Where-Object { $_ }))
    foreach ($candidate in $candidates) {
        $parts = @("$candidate" -split '\\' | Where-Object { $_ })
        for ($length = $parts.Count; $length -ge 1; $length--) {
            $relative = $parts[0..($length - 1)] -join '\'
            $key = $relative.ToLowerInvariant()
            if ($seen.ContainsKey($key)) { break }
            $targets = @(Get-DomainNamespaceTarget -Path ('\\{0}\{1}' -f $name, $relative))
            if ($targets.Count) {
                $seen[$key] = $true
                [pscustomobject]@{ Path = $relative; Targets = $targets }
                break
            }
        }
        if ($seen.Count -ge $script:DomainMaximumEntries) { break }
    }
}

function Get-DomainPolicyVersion {
    <#
    .SYNOPSIS
        The version of every policy in one controller's SYSVOL: its identifier and the
        number in its GPT.INI. $null where the controller did not say in time. Reads
        nothing else out of a policy. Never throws.
    .DESCRIPTION
        In a runspace of its own with a limit, for the reason Test-DomainShare gives. A
        policy whose file this account may not read has no version, and is left out by
        whoever compares.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([Parameter(Mandatory)][string]$Controller, [Parameter(Mandatory)][string]$Domain,
          [int]$TimeoutSeconds = $script:DomainPolicyTimeoutSeconds)

    $reader = 'param($root) foreach ($folder in @([IO.Directory]::GetDirectories($root))) { $version = $null; ' +
        'try { foreach ($line in [IO.File]::ReadAllLines((Join-Path $folder "GPT.INI"))) { if ($line -match "^\s*Version\s*=\s*(\d+)") { $version = [long]$Matches[1]; break } } } catch { }; ' +
        '[pscustomobject]@{ Id = [IO.Path]::GetFileName($folder); Version = $version } }'
    try {
        $shell = [powershell]::Create()
        $null  = $shell.AddScript($reader).AddArgument(('\\{0}\SYSVOL\{1}\Policies' -f $Controller, $Domain))
        $handle = $shell.BeginInvoke()
        if (-not $handle.AsyncWaitHandle.WaitOne($TimeoutSeconds * 1000)) { return $null }
        $read = @($shell.EndInvoke($handle))
        $failed = [bool]$shell.HadErrors
        $shell.Dispose()
        if ($failed -and -not $read.Count) { return $null }
        , @($read | ForEach-Object { [pscustomobject]@{ Id = "$($_.Id)"; Version = $_.Version } })
    }
    catch { $null }
}

# What Get-DomainData hands its Judge. See Private/DataShape.ps1.
$script:DomainDataShape = @{
    Joined      = 'Answered'
    Name        = 'Value'
    Site        = 'Value'
    LogonServer = 'Value'
    Computer    = 'Value'
    Addresses   = 'List'
    Roots       = 'List'
    Paths       = 'List'
    Asked       = 'Answered'
    Controllers = 'List'
    Namespace   = 'List'
    PolicyNames = 'List'
    Prefixes    = 'List'
    Routes      = 'List'
}

function Get-DomainData {
    <#
    .SYNOPSIS
        The domain of this machine, its controllers as DNS names them, and what each of
        them answers. Decides nothing.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([hashtable]$Parameters = @{}, [AllowNull()][hashtable]$Observed = @{})

    $joined = $false
    $name   = ''
    try {
        $system = Get-CimInstance Win32_ComputerSystem -ErrorAction Stop
        $joined = [bool]$system.PartOfDomain
        if ($joined) { $name = "$($system.Domain)".Trim() }
    }
    catch { }

    $data = [ordered]@{
        PSTypeName  = 'Gutcheck.Data.Domain'
        Joined      = $joined
        Name        = $name
        Site        = ''
        LogonServer = ("$env:LOGONSERVER".Trim().TrimStart('\'))
        # This machine's own name: Windows names it as the logon server of a session that
        # was signed in from cached credentials, with no controller asked.
        Computer    = "$env:COMPUTERNAME".Trim()
        Addresses   = @()
        Roots       = @()
        Paths       = @()
        Asked       = $false
        Controllers = @()
        Namespace   = @()
        PolicyNames = @()
        Prefixes    = @()
        Routes      = @()
    }
    if (-not $joined -or -not $name) { return [pscustomobject]$data }

    try { $data.Site = "$((Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\Netlogon\Parameters' -ErrorAction Stop).DynamicSiteName)".Trim() } catch { }
    $data.Addresses = @(Resolve-DomainAddress -Name $name)

    # The drives of the User that go by the domain's name: reconnected at sign-in, and
    # connected now.
    $paths = @()
    try { $paths += @(Get-ChildItem -Path 'HKCU:\Network' -ErrorAction Stop | ForEach-Object { "$((Get-ItemProperty -Path $_.PSPath -ErrorAction SilentlyContinue).RemotePath)" }) } catch { }
    try { $paths += @(Get-PSDrive -PSProvider FileSystem -ErrorAction Stop | ForEach-Object { "$($_.DisplayRoot)" }) } catch { }
    $data.Roots = @(Get-DomainMappedRoot -Path $paths -Domain $name | Select-Object -First $script:DomainMaximumRoots)
    $data.Paths = @(Get-DomainMappedPath -Path $paths -Domain $name | Select-Object -First $script:DomainMaximumPaths)

    $targets = @()
    if (Get-Command -Name Resolve-DnsName -ErrorAction SilentlyContinue) {
        $data.Asked = $true
        try {
            $targets = @(Resolve-DnsName -Name ('_ldap._tcp.dc._msdcs.{0}' -f $name) -Type SRV -DnsOnly -ErrorAction Stop |
                Where-Object { "$($_.Type)" -eq 'SRV' -and $_.NameTarget } | ForEach-Object { ConvertTo-ServerName -Name $_.NameTarget } |
                Sort-Object -Unique | Select-Object -First $script:DomainMaximumControllers)
        }
        catch { $targets = @() }
    }

    $shares = @('SYSVOL') + @($data.Roots) + @($data.Paths)
    $step = 0
    $data.Controllers = @(foreach ($target in $targets) {
        Write-CheckProgress -Status ((Get-Text 'Console.Progress.Connect') -f $target) -Step $step -Of $targets.Count
        $step++
        $addresses = @(Resolve-DomainAddress -Name $target)
        $smb = Measure-TcpConnect -HostName $target -Port $script:DomainSmbPort

        # The shares only of a controller that takes file connections at all: asked of
        # one that does not, each question waits out its limit.
        $answers = @()
        if ($null -ne $smb) {
            $answers = @(Test-DomainShare -Path @($shares | ForEach-Object { '\\{0}\{1}' -f $target, $_ }) | ForEach-Object {
                # What was asked for, without the controller in front of it.
                [pscustomobject]@{ Share = "$($_.Path)".Substring($target.Length + 3); Exists = $_.Exists; Ms = $_.Ms; TimedOut = $_.TimedOut } })
        }
        [pscustomobject]@{
            Name          = $target
            Addresses     = $addresses
            LdapMs        = Measure-TcpConnect -HostName $target -Port $script:DomainLdapPort
            KerberosMs    = Measure-TcpConnect -HostName $target -Port $script:DomainKerberosPort
            SmbMs         = $smb
            OffsetSeconds = Get-SntpOffset -Server $target -TimeoutMs 1500
            Shares        = $answers
            # The versions of its policies, where it takes file connections at all.
            Policies      = $(if ($null -ne $smb) { Get-DomainPolicyVersion -Controller $target -Domain $name })
        }
    })

    # The Servers behind the namespace the drives go through, each asked once.
    $measured = @{}
    $data.Namespace = @(Get-DomainNamespaceEntry -Root $data.Roots -Path $data.Paths -Domain $name | ForEach-Object {
        [pscustomobject]@{
            Path    = $_.Path
            Targets = @($_.Targets | ForEach-Object {
                $server = "$($_.Server)"
                if (-not $measured.ContainsKey($server)) {
                    $found = @(Resolve-DomainAddress -Name $server)
                    $measured[$server] = [pscustomobject]@{ Addresses = $found; SmbMs = $(if ($found.Count) { Measure-TcpConnect -HostName $server -Port $script:DomainSmbPort }) }
                }
                [pscustomobject]@{ Server = $server; Share = "$($_.Share)"; Online = [bool]$_.Online
                    Addresses = $measured[$server].Addresses; SmbMs = $measured[$server].SmbMs }
            })
        }
    })

    # What a policy is called, as far as the result of policy named it: SYSVOL knows a
    # policy by its identifier only.
    $named = @{}
    if ($Observed -and $Observed.ContainsKey('GroupPolicy')) {
        $result = Get-DataProperty $Observed['GroupPolicy'] 'Result'
        foreach ($scope in 'Computer', 'User') {
            foreach ($object in @((Get-DataCollection (Get-DataProperty $result $scope) 'Objects') | ForEach-Object { $_ })) {
                $id = "$(Get-DataProperty $object 'Id')".Trim()
                $title = "$(Get-DataProperty $object 'Name')".Trim()
                if ($id -and $title -and $title -ne $id) { $named[$id.ToUpperInvariant()] = $title }
            }
        }
    }
    $data.PolicyNames = @($named.Keys | Sort-Object | ForEach-Object { [pscustomobject]@{ Id = $_; Name = $named[$_] } })

    # What the Judge decides each measured address's Zone from: the machine's own subnets,
    # and the adapter the route to each address leaves through. Read, not judged.
    $addresses = @(@($data.Controllers | ForEach-Object { Select-ServerAddress -Address $_.Addresses }) +
        @($data.Namespace | ForEach-Object { $_.Targets } | ForEach-Object { Select-ServerAddress -Address $_.Addresses }))
    $data.Prefixes = @(Get-OwnPrefix)
    $data.Routes   = @($addresses | Where-Object { $_ } | Select-Object -Unique | ForEach-Object { Get-AddressRoute -Address $_ })

    [pscustomobject]$data
}

function Get-DomainControllerLabel {
    <#
    .SYNOPSIS
        A domain controller as a Finding names it: its host name, with its address. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$Controller)

    $name = (("$(Get-DataProperty $Controller 'Name')" -split '\.')[0]).ToUpperInvariant()
    $address = Select-ServerAddress -Address (Get-DataCollection $Controller 'Addresses')
    if ($address) { return ('{0} ({1})' -f $name, $address) }
    $name
}

function Get-DomainPortText {
    <#
    .SYNOPSIS
        What a port of a controller answered, as the Report shows it: how long the
        connection took, or that none came about. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$Milliseconds)

    $number = ConvertTo-Number $Milliseconds
    if ($null -eq $number) { return (Get-Text 'Value.Domain.NoAnswer') }
    (Get-Text 'Value.Domain.Milliseconds') -f [double]$number
}

function Get-DomainShareText {
    <#
    .SYNOPSIS
        What a controller answered about a share. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$Share)

    if ([bool](Get-DataProperty $Share 'TimedOut')) { return (Get-Text 'Value.Domain.Share.TimedOut') }
    if ([bool](Get-DataProperty $Share 'Exists')) {
        # How long the answer took where it took long: that is the wait at a sign-in.
        $ms = ConvertTo-Number (Get-DataProperty $Share 'Ms')
        if ($null -ne $ms -and $ms -ge 1000) { return ((Get-Text 'Value.Domain.Share.ThereSlow') -f ([double]$ms / 1000)) }
        return (Get-Text 'Value.Domain.Share.There')
    }
    Get-Text 'Value.Domain.Share.Missing'
}

function New-DomainControllerFinding {
    <#
    .SYNOPSIS
        The controllers of the domain, each with what its ports answered.
    .DESCRIPTION
        A warning where a controller does not answer on a port a member needs: the
        machine is sent to it as often as to the others. A failure where none answers
        LDAP: then there is no domain to sign in to.
    #>

    [CmdletBinding()]
    param([AllowNull()]$Data, [AllowEmptyCollection()]$Controller)

    $check = Get-Text 'Check.Domain.Controllers'
    $lines = @($Controller | ForEach-Object {
        (Get-Text 'Value.Domain.Controller') -f (Get-DomainControllerLabel -Controller $_),
            (Get-DomainPortText (Get-DataProperty $_ 'LdapMs')), (Get-DomainPortText (Get-DataProperty $_ 'KerberosMs')), (Get-DomainPortText (Get-DataProperty $_ 'SmbMs')) })
    $value = @((Get-Text 'Value.Domain.Controllers') -f $Controller.Count, "$(Get-DataProperty $Data 'Name')")
    $site = "$(Get-DataProperty $Data 'Site')"
    if ($site) { $value += (Get-Text 'Value.Domain.Site') -f $site }
    $value += (Get-Text 'Value.Domain.ControllerList') -f ($lines -join '; ')

    $silent = @($Controller | Where-Object {
        $null -eq (ConvertTo-Number (Get-DataProperty $_ 'LdapMs')) -or $null -eq (ConvertTo-Number (Get-DataProperty $_ 'KerberosMs')) -or
        $null -eq (ConvertTo-Number (Get-DataProperty $_ 'SmbMs')) })
    $noLdap = @($Controller | Where-Object { $null -eq (ConvertTo-Number (Get-DataProperty $_ 'LdapMs')) })

    $table = @(New-Reference -Section 'Title.Domain.Controllers')
    $severity = 'OK'
    if ($silent.Count) { $severity = 'WARN' }
    if ($noLdap.Count -eq $Controller.Count) { $severity = 'FAIL' }

    # Each Service of each controller in its own part, as the Network Map keys it: the port
    # that does not answer is what the Finding warns of, and the ports beside it that do are
    # in order, in the picture as in the table. A controller whose name did not resolve was
    # never found, and one that answers on none of its ports is not reached: either is a
    # part as a Server, the first instead of its Services. ADR-0015.
    $ports = @(@($script:DomainLdapPort, 'LdapMs'), @($script:DomainKerberosPort, 'KerberosMs'), @($script:DomainSmbPort, 'SmbMs'))
    $parts = [ordered]@{}
    foreach ($one in $Controller) {
        $name     = "$(Get-DataProperty $one 'Name')"
        $found    = [bool](Select-ServerAddress -Address (Get-DataCollection $one 'Addresses'))
        $answered = @($ports | Where-Object { $null -ne (ConvertTo-Number (Get-DataProperty $one $_[1])) } | ForEach-Object { $_[0] })
        if (-not $found -or -not $answered.Count) { $parts[$name] = $severity }
        if (-not $found) { continue }
        foreach ($port in $ports) {
            $parts[(Format-ServiceSubject -Server $name -Port $port[0])] = $(if ($answered -contains $port[0]) { 'OK' } else { $severity })
        }
    }
    if ($severity -eq 'OK') { return New-Finding -Category Domain -Check $check -Severity OK -Value ($value -join ' | ') -Reference $table -Subjects $parts }

    New-Finding -Category Domain -Check $check -Severity $severity -Value ($value -join ' | ') `
        -Meaning (Get-Text 'Meaning.Domain.ControllerSilent') -Hint (Get-Text 'Hint.Domain.ControllerSilent') -Reference $table `
        -Signal 'controller-differs' -Subjects $parts
}

function New-DomainLogonServerFinding {
    <#
    .SYNOPSIS
        Which domain controller took the sign-in of this session. Nothing where Windows
        names none.
    .DESCRIPTION
        One line, for the question asked first of any trouble with a sign-in: by which
        controller. Where Windows names this machine itself, no controller was asked -
        the session was signed in from what the machine remembered, as away from the
        network - and that is said, and is no fault.
    #>

    [CmdletBinding()]
    param([AllowNull()]$Data, [AllowEmptyCollection()]$Controller)

    $server = ConvertTo-ServerName -Name (Get-DataProperty $Data 'LogonServer')
    if (-not $server) { return }
    $check = Get-Text 'Check.Domain.LogonServer'
    $short = (($server -split '\.')[0]).ToUpperInvariant()

    $own = "$(Get-DataProperty $Data 'Computer')".Trim()
    if ($own -and (Test-SameServer -Name $server -Other $own)) {
        return New-Finding -Category Domain -Check $check -Severity INFO -Value (Get-Text 'Value.Domain.LogonServer.Cached') `
            -Meaning (Get-Text 'Meaning.Domain.LogonServer.Cached') -Hint (Get-Text 'Hint.Domain.LogonServer.Cached')
    }

    # With its address, where it is one of the controllers that were measured.
    $known = @($Controller | Where-Object { Test-SameServer -Name (Get-DataProperty $_ 'Name') -Other $server })[0]
    $value = $short
    if ($known) { $value = Get-DomainControllerLabel -Controller $known }
    New-Finding -Category Domain -Check $check -Severity OK -Value $value -Reference @(New-Reference -Section 'Title.Domain.Controllers')
}

function New-DomainShareFinding {
    <#
    .SYNOPSIS
        Whether every controller has the shares a member takes from any of them.
    .DESCRIPTION
        SYSVOL missing on a controller is a warning: Group Policy is read from whichever
        answers. A folder the drives go by that is there on one controller and not on
        another is a warning: that is the drive that comes up at once on some days. One
        that is on none of them is a namespace other servers hold, and in order.
    #>

    [CmdletBinding()]
    param([AllowNull()]$Data, [AllowEmptyCollection()]$Controller, [hashtable]$Parameters = @{})

    $asked = @($Controller | Where-Object { @((Get-DataCollection $_ 'Shares') | ForEach-Object { $_ }).Count })
    if (-not $asked.Count) { return }
    # From how long an answer for something that is there is a wait worth a warning.
    $slowMs = 1000 * [double](Get-Parameter $Parameters 'ShareSlowSeconds' 5)
    $slow   = @()

    $shares = @($asked | ForEach-Object { (Get-DataCollection $_ 'Shares') | ForEach-Object { "$(Get-DataProperty $_ 'Share')" } } | Select-Object -Unique)
    $lines  = @()
    $uneven = @()
    $noSysvol = @()
    # The controllers each warning is about: the slow one, the one without SYSVOL, the one
    # without a folder another has. Each is the part of the picture that warns.
    $warned = @{}
    foreach ($share in $shares) {
        $answers = @(foreach ($one in $asked) {
            $answer = @((Get-DataCollection $one 'Shares') | Where-Object { "$(Get-DataProperty $_ 'Share')" -eq $share })[0]
            if ($answer) { [pscustomobject]@{ Controller = $one; Answer = $answer; There = [bool](Get-DataProperty $answer 'Exists') } }
        })
        $lines += (Get-Text 'Value.Domain.Share') -f $share, (@($answers | ForEach-Object {
            '{0} {1}' -f ((("$(Get-DataProperty $_.Controller 'Name')" -split '\.')[0]).ToUpperInvariant()), (Get-DomainShareText -Share $_.Answer) }) -join ', ')

        $there = @($answers | Where-Object { $_.There })
        foreach ($answer in $there) {
            $ms = ConvertTo-Number (Get-DataProperty $answer.Answer 'Ms')
            if ($null -ne $ms -and $ms -ge $slowMs) {
                $slow += (Get-Text 'Value.Domain.Share.SlowAt') -f $share, ((("$(Get-DataProperty $answer.Controller 'Name')" -split '\.')[0]).ToUpperInvariant()), ([double]$ms / 1000)
                $warned["$(Get-DataProperty $answer.Controller 'Name')"] = $true
            }
        }
        $lacking = @($answers | Where-Object { -not $_.There })
        if ($share -eq 'SYSVOL') { if ($there.Count -lt $answers.Count) { $noSysvol += $lacking } }
        elseif ($there.Count -and $there.Count -lt $answers.Count) { $uneven += $share }
        else { $lacking = @() }
        foreach ($one in $lacking) { $warned["$(Get-DataProperty $one.Controller 'Name')"] = $true }
    }

    $check = Get-Text 'Check.Domain.Shares'
    $value = $lines -join ' | '
    $table = @(New-Reference -Section 'Title.Domain.Controllers')
    $severity = $(if ($noSysvol.Count -or $uneven.Count -or $slow.Count) { 'WARN' } else { 'OK' })
    # The SMB Service of each controller in its own part, as the Finding about the
    # controllers has it: the shares are asked for over SMB, and the answer times of the one
    # beside a slow one are in order. ADR-0015.
    $parts = [ordered]@{}
    foreach ($one in $asked) {
        $name = "$(Get-DataProperty $one 'Name')"
        $parts[(Format-ServiceSubject -Server $name -Port $script:DomainSmbPort)] = $(if ($warned.ContainsKey($name)) { $severity } else { 'OK' })
    }
    if ($severity -eq 'OK') { return New-Finding -Category Domain -Check $check -Severity OK -Value $value -Reference $table -Subjects $parts }

    $meaning = @()
    $hint    = @()
    if ($slow.Count) {
        $meaning += (Get-Text 'Meaning.Domain.Share.Slow') -f ($slow -join ', ')
        $hint    += Get-Text 'Hint.Domain.Share.Slow'
    }
    if ($uneven.Count) {
        $meaning += (Get-Text 'Meaning.Domain.Share.Uneven') -f ($uneven -join ', ')
        $hint    += Get-Text 'Hint.Domain.Share.Uneven'
    }
    if ($noSysvol.Count) {
        $meaning += Get-Text 'Meaning.Domain.Share.NoSysvol'
        $hint    += Get-Text 'Hint.Domain.Share.NoSysvol'
    }
    New-Finding -Category Domain -Check $check -Severity WARN -Value $value `
        -Meaning ($meaning -join ' | ') -Hint ($hint -join ' | ') -Reference $table -Signal 'controller-differs' -Subjects $parts
}

function New-DomainNameFinding {
    <#
    .SYNOPSIS
        Whether the domain's own name leads only to its controllers. Nothing where the
        name was not resolved.
    .DESCRIPTION
        \\contoso.example\... is sent to an address the domain's name resolves to. One of
        those that no controller has is a record somebody forgot - a controller that was
        retired - and every connection that draws it waits for nothing.
    #>

    [CmdletBinding()]
    param([AllowNull()]$Data, [AllowEmptyCollection()]$Controller)

    $addresses = @((Get-DataCollection $Data 'Addresses') | ForEach-Object { "$_" } | Where-Object { $_ })
    if (-not $addresses.Count) { return }

    $known = @($Controller | ForEach-Object { (Get-DataCollection $_ 'Addresses') | ForEach-Object { "$_" } })
    $stray = @($addresses | Where-Object { $known -notcontains $_ })
    $check = Get-Text 'Check.Domain.Name'
    $value = (Get-Text 'Value.Domain.Name') -f "$(Get-DataProperty $Data 'Name')", ($addresses -join ', ')
    if (-not $stray.Count) { return New-Finding -Category Domain -Check $check -Severity OK -Value $value }

    New-Finding -Category Domain -Check $check -Severity WARN `
        -Value ('{0} | {1}' -f $value, ((Get-Text 'Value.Domain.Name.Stray') -f ($stray -join ', '))) `
        -Meaning (Get-Text 'Meaning.Domain.Name.Stray') -Hint (Get-Text 'Hint.Domain.Name.Stray')
}

function Get-DomainTargetState {
    <#
    .SYNOPSIS
        How a Server behind a namespace entry stands: 'Fine', 'Off' where the namespace
        has it switched off, 'NoName' where its name does not resolve, 'Silent' where it
        resolves and takes no file connection. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$Target)

    if (-not [bool](Get-DataProperty $Target 'Online')) { return 'Off' }
    if (-not @((Get-DataCollection $Target 'Addresses') | ForEach-Object { "$_" } | Where-Object { $_ }).Count) { return 'NoName' }
    if ($null -eq (ConvertTo-Number (Get-DataProperty $Target 'SmbMs'))) { return 'Silent' }
    'Fine'
}

function Get-DomainTargetStateText {
    <#
    .SYNOPSIS
        What the Report calls the state of a Server behind a namespace entry. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyString()][string]$State)

    switch ($State) {
        'Off'    { return (Get-Text 'Value.Domain.Target.Off') }
        'NoName' { return (Get-Text 'Value.Domain.Target.NoName') }
        'Silent' { return (Get-Text 'Value.Domain.Target.Silent') }
    }
    Get-Text 'Value.Domain.Target.Fine'
}

function Get-DomainNamespaceEnd {
    <#
    .SYNOPSIS
        The Namespace a namespace entry is under, as a Link names it: the root of its path,
        \\<domain>\<first folder>. Nothing where the machine is in no domain, the domain
        has no name, or the path makes no root. Pure.
    .DESCRIPTION
        The one end the Network Map draws the Namespace by (ConvertTo-DomainLink) and the
        Finding names it by (New-DomainNamespaceFinding), so that its part is the node. #172.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Data, [AllowNull()]$Entry)

    if (-not [bool](Get-DataProperty $Data 'Joined')) { return }
    $name = "$(Get-DataProperty $Data 'Name')".Trim()
    if (-not $name) { return }
    try { New-LinkEnd -Thing Namespace -Path ('\\{0}\{1}' -f $name, "$(Get-DataProperty $Entry 'Path')".Trim()) } catch { $null }
}

function New-DomainNamespaceFinding {
    <#
    .SYNOPSIS
        Whether each folder of the namespace the mapped drives go through has a Server that
        is there. Nothing where the drives go through no namespace.
    .DESCRIPTION
        DFS is no cluster: a folder is one path to several Servers, and the client fails
        over from one to the next (#172). So each folder is judged by the Servers the
        namespace has switched on for it; one it has switched off is handed to no client,
        and is said without a warning. A Server whose name does not resolve, or that takes
        no file connection, is gone.
        - Every one of them there: in order.
        - Some gone and one still there: a warning, namespace-target-gone. A client that is
          sent to the one that is gone waits before it tries the next, and that is a drive
          that comes late.
        - None of them there, or none switched on: a FAIL, namespace-unreachable. Nothing
          serves the folder.
        The Finding is as bad as its worst folder, and says the dead ones first.
    #>

    [CmdletBinding()]
    param([AllowNull()]$Data)

    $entries = @((Get-DataCollection $Data 'Namespace') | ForEach-Object { $_ })
    if (-not $entries.Count) { return }

    $folders = @(foreach ($entry in $entries) {
        $path = "$(Get-DataProperty $entry 'Path')"
        $targets = @(foreach ($target in @((Get-DataCollection $entry 'Targets') | ForEach-Object { $_ })) {
            [pscustomobject]@{ Path = $path; Server = "$(Get-DataProperty $target 'Server')"
                Share = "$(Get-DataProperty $target 'Share')"; State = Get-DomainTargetState -Target $target
                Found = [bool](Select-ServerAddress -Address (Get-DataCollection $target 'Addresses')) }
        })
        # Off targets do not count: a folder lives as long as one it hands out answers. Gone
        # is one it hands out that is not there, read once here for the folder and its Server.
        $on   = @($targets | Where-Object { $_.State -ne 'Off' })
        $fine = @($on | Where-Object { $_.State -eq 'Fine' })
        $gone = @($on | Where-Object { $_.State -ne 'Fine' })
        $severity = $(if (-not $fine.Count) { 'FAIL' } elseif ($gone.Count) { 'WARN' } else { 'OK' })
        # Its Namespace as the Network Map draws it, by the one end ConvertTo-DomainLink draws.
        $end = Get-DomainNamespaceEnd -Data $Data -Entry $entry
        [pscustomobject]@{ Path = $path; Root = $(if ($end) { $end.Key } else { '' }); Targets = $targets; Fine = $fine; Gone = $gone; Severity = $severity }
    })
    $all = @($folders | ForEach-Object { $_.Targets })
    # One Server however the namespace named it (Get-ServerKey), so that it is counted
    # once and has one part.
    $among   = @($all | ForEach-Object { $_.Server })
    $servers = @($all | ForEach-Object { Get-ServerKey -Name $_.Server -Among $among } | Select-Object -Unique)
    $off     = @($all | Where-Object { $_.State -eq 'Off' })
    $dead    = @($folders | Where-Object Severity -eq 'FAIL')
    $weak    = @($folders | Where-Object Severity -eq 'WARN')
    $severity = Get-WorstSeverity @($folders | ForEach-Object { $_.Severity })

    # Each Namespace by its root, as the Network Map keys it, in the colour of its worst
    # folder: a part of its own, never the Server the root is on (#172). Then each Server's
    # SMB Service in its own part, as the namespace names the Server first: one that is gone
    # is as bad as the worst folder it is gone in - a warning where another Server still has
    # the folder, a FAIL where none has - and one that is there, or switched off, is in
    # order. A Server whose name does not resolve is the part itself: it was never found,
    # and its SMB never asked. ADR-0015.
    #
    # Every folder whose root can be named has its part. One whose root cannot - the domain
    # has no name, the entry no path - has no Namespace on the Map either (ConvertTo-DomainLink
    # draws none from the same end), so a part would colour nothing and name what nobody can
    # find; its Servers are judged and coloured all the same.
    $parts = [ordered]@{}
    foreach ($folder in @($folders | Where-Object Root)) {
        $parts[$folder.Root] = $(if ($parts.Contains($folder.Root)) { Get-WorstSeverity $parts[$folder.Root] $folder.Severity } else { $folder.Severity })
    }
    $named = @{}
    foreach ($folder in $folders) {
        foreach ($one in $folder.Targets) {
            $key = Get-ServerKey -Name $one.Server -Among $among
            if (-not $named.ContainsKey($key)) {
                $named[$key] = $(if ($one.Found) { Format-ServiceSubject -Server $one.Server -Port $script:DomainSmbPort } else { $one.Server })
                $parts[$named[$key]] = 'OK'
            }
            $known = $named[$key]
            if ($folder.Gone -contains $one) { $parts[$known] = Get-WorstSeverity $parts[$known] $folder.Severity }
        }
    }

    $check = Get-Text 'Check.Domain.Namespace'
    $table = @(New-Reference -Section 'Title.Domain.Namespace')
    $value = @((Get-Text 'Value.Domain.Namespace.Count') -f $entries.Count, $servers.Count)
    $short = { param($one) ($one.Server -split '\.')[0].ToUpperInvariant() }
    $item  = { param($one) (Get-Text 'Value.Domain.Namespace.Item') -f $one.Path, (& $short $one), (Get-DomainTargetStateText -State $one.State) }

    # The dead folders first, each with the Servers it hands out and how each stands. Then
    # the Servers that are gone from a folder that another one still serves, and which.
    if ($dead.Count) {
        $value += (Get-Text 'Value.Domain.Namespace.Dead') -f (@($dead | ForEach-Object {
            $said = $(if ($_.Gone.Count) { @($_.Gone | ForEach-Object { (Get-Text 'Value.Domain.Namespace.TargetItem') -f (& $short $_), (Get-DomainTargetStateText -State $_.State) }) -join ', ' }
                      else { Get-Text 'Value.Domain.Namespace.AllOff' })
            (Get-Text 'Value.Domain.Namespace.DeadItem') -f $_.Path, $said
        }) -join '; ')
    }
    if ($weak.Count) {
        $value += (Get-Text 'Value.Domain.Namespace.Gone') -f (@($weak | ForEach-Object { $_.Gone } | ForEach-Object { & $item $_ }) -join '; ')
        $value += (Get-Text 'Value.Domain.Namespace.Still') -f (@($weak | ForEach-Object {
            (Get-Text 'Value.Domain.Namespace.StillItem') -f $_.Path, (@($_.Fine | ForEach-Object { & $short $_ } | Select-Object -Unique) -join ', ') }) -join '; ')
    }
    if ($off.Count) { $value += (Get-Text 'Value.Domain.Namespace.Off') -f (@($off | ForEach-Object { & $item $_ }) -join '; ') }

    if ($severity -eq 'OK') {
        $value[0] = '{0}, {1}' -f $value[0], (Get-Text 'Value.Domain.Namespace.AllThere')
        return New-Finding -Category Domain -Check $check -Severity OK -Value ($value -join ' | ') -Reference $table -Subjects $parts
    }
    $signal = @()
    if ($dead.Count) { $signal += 'namespace-unreachable' }
    if ($weak.Count) { $signal += 'namespace-target-gone' }
    # A folder that nothing serves is said and done first: its Server back, or another in.
    $meaning = $(if ($dead.Count) { Get-Text 'Meaning.Domain.Namespace.Dead' } else { Get-Text 'Meaning.Domain.Namespace.Degraded' })
    $hint    = $(if ($dead.Count) { Get-Text 'Hint.Domain.Namespace.Dead' } else { Get-Text 'Hint.Domain.Namespace.Degraded' })
    New-Finding -Category Domain -Check $check -Severity $severity -Value ($value -join ' | ') -Meaning $meaning -Hint $hint `
        -Signal $signal -Reference $table -Subjects $parts
}

function New-DomainPolicyFinding {
    <#
    .SYNOPSIS
        Whether every controller hands out the same policies: the versions in each one's
        SYSVOL, compared. Nothing where fewer than two controllers were read.
    .DESCRIPTION
        A policy at one version on one controller and at another on the next, or there
        on one and not on the other, is a warning: replication between them is behind or
        has stopped, and what a machine is told depends on which of them answers. A
        policy this account may not read on either is left out.
    #>

    [CmdletBinding()]
    param([AllowNull()]$Data, [AllowEmptyCollection()]$Controller)

    $short = { param($one) (("$(Get-DataProperty $one 'Name')" -split '\.')[0]).ToUpperInvariant() }
    $read  = @($Controller | Where-Object { $null -ne (Get-DataProperty $_ 'Policies') })
    $unread = @($Controller | Where-Object { $null -eq (Get-DataProperty $_ 'Policies') })
    if ($read.Count -lt 2) { return }

    $names = @{}
    foreach ($entry in @((Get-DataCollection $Data 'PolicyNames') | ForEach-Object { $_ })) { $names["$(Get-DataProperty $entry 'Id')".ToUpperInvariant()] = "$(Get-DataProperty $entry 'Name')" }

    # Per policy, what each controller has: a version, or nothing where it has none.
    $table = @{}
    foreach ($one in $read) {
        foreach ($policy in @((Get-DataCollection $one 'Policies') | ForEach-Object { $_ })) {
            $id = "$(Get-DataProperty $policy 'Id')".ToUpperInvariant()
            if (-not $id) { continue }
            if (-not $table.ContainsKey($id)) { $table[$id] = @{} }
            $table[$id][(& $short $one)] = Get-DataProperty $policy 'Version'
        }
    }
    $apart = @(foreach ($id in @($table.Keys | Sort-Object)) {
        $seen = $table[$id]
        # Readable nowhere: not this account's to compare.
        if (-not @($seen.Values | Where-Object { $null -ne $_ }).Count) { continue }
        $versions = @($read | ForEach-Object { $key = & $short $_; if ($seen.ContainsKey($key)) { "$($seen[$key])" } else { '-' } })
        if (@($versions | Select-Object -Unique).Count -gt 1) {
            $title = $(if ($names.ContainsKey($id)) { $names[$id] } else { $id })
            (Get-Text 'Value.Domain.Policy.Item') -f $title, (@($read | ForEach-Object {
                $key = & $short $_
                if (-not $seen.ContainsKey($key)) { (Get-Text 'Value.Domain.Policy.Missing') -f $key }
                elseif ($null -eq $seen[$key]) { (Get-Text 'Value.Domain.Policy.Unreadable') -f $key }
                else { (Get-Text 'Value.Domain.Policy.Version') -f $key, $seen[$key] } }) -join ', ')
        }
    })

    $check = Get-Text 'Check.Domain.Policies'
    $value = @((Get-Text 'Value.Domain.Policy.Count') -f $table.Count, $read.Count)
    if ($unread.Count) { $value += (Get-Text 'Value.Domain.Policy.NotRead') -f (@($unread | ForEach-Object { & $short $_ }) -join ', ') }
    if (-not $apart.Count) {
        $value[0] = '{0}, {1}' -f $value[0], (Get-Text 'Value.Domain.Policy.Same')
        return New-Finding -Category Domain -Check $check -Severity OK -Value ($value -join ' | ')
    }
    $shown = @($apart | Select-Object -First 8)
    if ($apart.Count -gt 8) { $shown += (Get-Text 'Value.NetworkProtocol.More') -f ($apart.Count - 8) }
    $value += (Get-Text 'Value.Domain.Policy.Apart') -f ($shown -join '; ')
    New-Finding -Category Domain -Check $check -Severity WARN -Value ($value -join ' | ') `
        -Meaning (Get-Text 'Meaning.Domain.Policy.Apart') -Hint (Get-Text 'Hint.Domain.Policy.Apart') -Signal 'policies-differ'
}

function ConvertTo-DomainFinding {
    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Data, [hashtable]$Parameters = @{}, [AllowNull()]$Situation)

    $check = Get-Text 'Check.Domain.Controllers'
    if (-not [bool](Get-DataProperty $Data 'Joined')) {
        return New-Finding -Category Domain -Check $check -Severity OK -Value (Get-Text 'Value.Domain.NotJoined')
    }

    $controllers = @((Get-DataCollection $Data 'Controllers') | ForEach-Object { $_ })
    if (-not $controllers.Count) {
        # DNS names none: away from its network a notebook finds none, and that is no
        # fault; at its desk a machine that finds none has no domain.
        if (-not [bool](Get-DataProperty $Data 'Asked')) {
            New-UnavailableFinding -Category Domain -Check $check -Hint (Get-Text 'Hint.Domain.NotAsked')
        }
        else {
            $severity = Select-SituationText -Situation $Situation -Fact Form -Text @{ Notebook = 'INFO'; Unknown = 'WARN' }
            New-Finding -Category Domain -Check $check -Severity $severity `
                -Value ((Get-Text 'Value.Domain.NoControllers') -f "$(Get-DataProperty $Data 'Name')") `
                -Meaning (Get-Text 'Meaning.Domain.NoControllers') -Hint (Get-Text 'Hint.Domain.NoControllers')
        }
        # The namespace was read without the controllers, and still leads where it leads.
        return ConvertTo-DomainLink -Data $Data
    }

    $controllerFinding = New-DomainControllerFinding -Data $Data -Controller $controllers
    $shareFinding      = New-DomainShareFinding -Data $Data -Controller $controllers -Parameters $Parameters
    $namespaceFinding  = New-DomainNamespaceFinding -Data $Data
    @(
        New-DomainLogonServerFinding -Data $Data -Controller $controllers
        $controllerFinding
        $shareFinding
        New-DomainNameFinding -Data $Data -Controller $controllers
        $namespaceFinding
        New-DomainPolicyFinding -Data $Data -Controller $controllers
    ) | Where-Object { $_ }

    # And the numbers those Findings judge, each naming the Finding and the Subject it
    # belongs to, for the Report's picture of the network. ADR-0009.
    ConvertTo-DomainMeasurement -Data $Data -Parameters $Parameters -ControllerFinding $controllerFinding -ShareFinding $shareFinding -NamespaceFinding $namespaceFinding

    # And how the Domain hangs together, for the Network Map. ADR-0012.
    ConvertTo-DomainLink -Data $Data
}

function ConvertTo-DomainLink {
    <#
    .SYNOPSIS
        How the Domain hangs together, as this Check observed it: its controllers, and its
        Namespaces with where each leads. Pure.
    .DESCRIPTION
        Nothing new is gathered: every Link is read off what the Gatherer already returns
        for the Findings and Measurements. ADR-0012.
        - ControllerOf: each controller DNS names for the Domain, at the address its
          Measurements carry (Select-ServerAddress), or by its name alone.
        - NamespaceOf: the root of each namespace entry a mapped drive goes through,
          \\<domain>\<first folder>, once however many entries it has.
        - LeadsTo and OnServer: the root to the share each entry names on each Server,
          and that share on its Server; to the Server itself where it names no share. A
          Server that is gone or switched off in the namespace is where it leads all the
          same: whether it works is the Findings' to say.
        No InSite: the controllers are asked of DNS for the whole Domain, not for a site,
        and the site the data carries is this machine's own.
        Each Link once, however often the data names it.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Data)

    if (-not [bool](Get-DataProperty $Data 'Joined')) { return }
    $name = "$(Get-DataProperty $Data 'Name')".Trim()
    if (-not $name) { return }

    # Each Link once at the end (Select-UniqueLink): a namespace names its root per entry.
    $links = [System.Collections.Generic.List[psobject]]::new()
    $say = { param([string]$Kind, $From, $To) $links.Add((New-Link -Kind $Kind -From $From -To $To)) }
    $addressOf = { param($Of) Select-ServerAddress -Address (Get-DataCollection $Of 'Addresses') }
    $domain = $null
    try { $domain = New-LinkEnd -Thing Domain -Name $name } catch { return }

    foreach ($controller in (Get-DataCollection $Data 'Controllers')) {
        $server = $null
        try { $server = New-LinkEnd -Thing Server -Name "$(Get-DataProperty $controller 'Name')" -Address (& $addressOf $controller) } catch { continue }
        & $say ControllerOf $server $domain
    }

    foreach ($entry in (Get-DataCollection $Data 'Namespace')) {
        $namespace = Get-DomainNamespaceEnd -Data $Data -Entry $entry
        if (-not $namespace) { continue }
        & $say NamespaceOf $namespace $domain
        foreach ($target in (Get-DataCollection $entry 'Targets')) {
            $address = & $addressOf $target
            $server = $null
            try { $server = New-LinkEnd -Thing Server -Name "$(Get-DataProperty $target 'Server')" -Address $address } catch { continue }
            $shareName = "$(Get-DataProperty $target 'Share')".Trim()
            if (-not $shareName) {
                & $say LeadsTo $namespace $server
                continue
            }
            $share = New-LinkEnd -Thing Share -Server $server.Key -Name $shareName -Address $address
            & $say LeadsTo $namespace $share
            & $say OnServer $share $server
        }
    }
    Select-UniqueLink -Link $links
}

function ConvertTo-DomainMeasurement {
    <#
    .SYNOPSIS
        What this Check measured the way with: LDAP, Kerberos and SMB of each domain
        controller, how far its clock is from this one, how long it took to answer for each
        share, and the SMB connect to each Server behind the namespace. Pure.
    .DESCRIPTION
        Each names the Finding that judges it - the controllers', the shares', or the
        namespace's - and carries as its Subject and its Port the Service that Finding gives
        the part about it, or as its Subject the Server where the part is the Server's, so
        that a view colours a reachable Server by its own part and not by the one beside it
        that is gone (ADR-0015). No Finding of this Check judges a controller's clock: its
        offset names none, and is drawn grey.
 
        A name that did not resolve was not connected to: NotMeasured, with no Zone and the
        reason in words, never 0. One that resolved and did not answer failed.
 
        The answer for a share is a Response on SMB's port, one per share and controller,
        grouped by the path asked for: SMB on 445 is the connect, already on the card. A
        share the controller does not have failed, with the time its "no" took; one it did
        not answer for in time failed with none. A controller that took no file connection
        was not asked for any share (Get-DomainData): each is NotMeasured.
    .PARAMETER ControllerFinding
        The Finding about the controllers ConvertTo-DomainFinding made of the same data.
    .PARAMETER ShareFinding
        The Finding about the shares of the controllers, where there is one.
    .PARAMETER NamespaceFinding
        The Finding about the Servers behind the namespace, where there is one.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Data, [hashtable]$Parameters = @{}, [AllowNull()]$ControllerFinding, [AllowNull()]$ShareFinding,
          [AllowNull()]$NamespaceFinding)

    $vpnPattern = Get-Parameter $Parameters 'VpnAdapterPattern' $script:MeasurementVpnAdapterPattern

    # One connect to one port, as the Gatherer made it.
    $connect = {
        param([string]$Subject, [string[]]$Addresses, [string]$Role, [string]$Metric, [int]$Port, $Milliseconds, $Belongs)
        $address = Select-ServerAddress -Address $Addresses
        $common = @{ Subject = $Subject; Address = $address; Role = $Role; Metric = $Metric; Port = $Port; Finding = $Belongs
                     Zone = (Get-GatheredAddressZone -Data $Data -Address $address -VpnPattern $vpnPattern) }
        $ms = ConvertTo-Number $Milliseconds
        if ($null -ne $ms) { New-Measurement @common -Samples 1 -Value $ms }
        elseif (-not $address) { New-Measurement @common -State NotMeasured -ErrorText (Get-Text 'Measurement.Error.NoName') }
        else { New-Measurement @common -Samples 1 -State Failed -ErrorText (Get-Text 'Measurement.Error.NoConnection') }
    }

    # What each controller was asked for, as the Gatherer asks: SYSVOL, then the folders and
    # the paths of the drives. For a controller that was asked for none.
    $wanted = @(@('SYSVOL') + @((Get-DataCollection $Data 'Roots') | ForEach-Object { "$_" }) +
        @((Get-DataCollection $Data 'Paths') | ForEach-Object { "$_" }) | Where-Object { $_ } | Select-Object -Unique)

    $controllers = @((Get-DataCollection $Data 'Controllers') | ForEach-Object { $_ })
    foreach ($controller in $controllers) {
        $name = "$(Get-DataProperty $controller 'Name')"
        $addresses = @((Get-DataCollection $controller 'Addresses') | ForEach-Object { "$_" })
        & $connect $name $addresses 'DomainController' 'Ldap'     $script:DomainLdapPort     (Get-DataProperty $controller 'LdapMs')     $ControllerFinding
        & $connect $name $addresses 'DomainController' 'Kerberos' $script:DomainKerberosPort (Get-DataProperty $controller 'KerberosMs') $ControllerFinding
        & $connect $name $addresses 'DomainController' 'Smb'      $script:DomainSmbPort      (Get-DataProperty $controller 'SmbMs')      $ControllerFinding

        # The rest at the same address and in the same Zone, so that it lands on the node.
        $address = Select-ServerAddress -Address $addresses
        $common  = @{ Subject = $name; Address = $address; Role = 'DomainController'
                      Zone = (Get-GatheredAddressZone -Data $Data -Address $address -VpnPattern $vpnPattern) }

        # The clock, in seconds as measured: ahead of this one or behind, with its sign.
        $clock  = @{ Metric = 'ClockOffset'; Port = $script:DomainSntpPort } + $common
        $offset = ConvertTo-Number (Get-DataProperty $controller 'OffsetSeconds')
        if ($null -ne $offset) { New-Measurement @clock -Samples 1 -Value $offset }
        elseif (-not $address) { New-Measurement @clock -State NotMeasured -ErrorText (Get-Text 'Measurement.Error.NoName') }
        else { New-Measurement @clock -Samples 1 -State Failed -ErrorText (Get-Text 'Measurement.Error.NoReply') }

        $share   = @{ Metric = 'Response'; Port = $script:DomainSmbPort; Finding = $ShareFinding } + $common
        $answers = @((Get-DataCollection $controller 'Shares') | ForEach-Object { $_ })
        if (-not $answers.Count) {
            $why = $(if ($address) { Get-Text 'Measurement.Error.Domain.ShareNotAsked' } else { Get-Text 'Measurement.Error.NoName' })
            foreach ($path in $wanted) { New-Measurement @share -Group ('\\{0}\{1}' -f $name, $path) -State NotMeasured -ErrorText $why }
        }
        foreach ($answer in $answers) {
            $group = '\\{0}\{1}' -f $name, "$(Get-DataProperty $answer 'Share')"
            $ms    = ConvertTo-Number (Get-DataProperty $answer 'Ms')
            if ([bool](Get-DataProperty $answer 'TimedOut')) {
                New-Measurement @share -Group $group -Samples 1 -State Failed -ErrorText (Get-Text 'Value.Domain.Share.TimedOut')
            }
            elseif (-not [bool](Get-DataProperty $answer 'Exists')) {
                New-Measurement @share -Group $group -Samples 1 -State Failed -Value $ms -ErrorText (Get-Text 'Value.Domain.Share.Missing')
            }
            elseif ($null -ne $ms) { New-Measurement @share -Group $group -Samples 1 -Value $ms }
        }
    }

    # Each Server behind the namespace once, by the name the namespace gave it first: the
    # Gatherer asked each once, and the Finding's part about it goes by the same name.
    $seen = @{}
    $among = @((Get-DataCollection $Data 'Namespace') | ForEach-Object { (Get-DataCollection $_ 'Targets') } | ForEach-Object { "$(Get-DataProperty $_ 'Server')" })
    foreach ($entry in @((Get-DataCollection $Data 'Namespace') | ForEach-Object { $_ })) {
        foreach ($target in @((Get-DataCollection $entry 'Targets') | ForEach-Object { $_ })) {
            $server = "$(Get-DataProperty $target 'Server')"
            $key    = Get-ServerKey -Name $server -Among $among
            if (-not $key -or $seen.ContainsKey($key)) { continue }
            $seen[$key] = $true
            & $connect $server @((Get-DataCollection $target 'Addresses') | ForEach-Object { "$_" }) 'DfsTarget' 'Smb' $script:DomainSmbPort (Get-DataProperty $target 'SmbMs') $NamespaceFinding
        }
    }
}

function ConvertTo-DomainSection {
    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Data)

    if (-not [bool](Get-DataProperty $Data 'Joined')) { return }
    $controllers = @((Get-DataCollection $Data 'Controllers') | ForEach-Object { $_ })

    New-Section -Title (Get-Text 'Title.Domain.Controllers') -Help (Get-Text 'Help.Domain.Controllers') -Row @(
        foreach ($controller in $controllers) {
            $offset = ConvertTo-Number (Get-DataProperty $controller 'OffsetSeconds')
            $row = [ordered]@{}
            $row[(Get-Text 'Column.Domain.Controller')] = "$(Get-DataProperty $controller 'Name')"
            $row[(Get-Text 'Column.NetworkDisruption.Address')] = @((Get-DataCollection $controller 'Addresses') | ForEach-Object { "$_" }) -join ', '
            $row[(Get-Text 'Column.Domain.Ldap')]     = Get-DomainPortText (Get-DataProperty $controller 'LdapMs')
            $row[(Get-Text 'Column.Domain.Kerberos')] = Get-DomainPortText (Get-DataProperty $controller 'KerberosMs')
            $row[(Get-Text 'Column.Domain.Smb')]      = Get-DomainPortText (Get-DataProperty $controller 'SmbMs')
            $row[(Get-Text 'Column.Domain.Clock')]    = $(if ($null -eq $offset) { Get-Text 'Value.Domain.NoAnswer' } else { (Get-Text 'Value.Domain.Seconds') -f [double]$offset })
            $row[(Get-Text 'Column.Domain.Shares')]   = @((Get-DataCollection $controller 'Shares') | ForEach-Object {
                '{0}: {1}' -f "$(Get-DataProperty $_ 'Share')", (Get-DomainShareText -Share $_) }) -join '; '
            [pscustomobject]$row
        }
    )

    $name  = Get-Text 'Column.NetworkProtocol.Dns.Setting'
    $value = Get-Text 'Column.NetworkProtocol.Dns.Value'
    $none  = Get-Text 'Value.NetworkProtocol.Dns.None'
    $line  = {
        param([string]$Label, [string]$Text)
        $row = [ordered]@{}
        $row[$name]  = $Label
        $row[$value] = $(if ($Text) { $Text } else { $none })
        [pscustomobject]$row
    }
    New-Section -Title (Get-Text 'Title.Domain.Membership') -Help (Get-Text 'Help.Domain.Membership') -Row @(
        & $line (Get-Text 'Value.Domain.Setting.Name') "$(Get-DataProperty $Data 'Name')"
        & $line (Get-Text 'Value.Domain.Setting.Site') "$(Get-DataProperty $Data 'Site')"
        & $line (Get-Text 'Value.Domain.Setting.LogonServer') "$(Get-DataProperty $Data 'LogonServer')"
        & $line (Get-Text 'Value.Domain.Setting.Addresses') (@((Get-DataCollection $Data 'Addresses') | ForEach-Object { "$_" }) -join ', ')
        & $line (Get-Text 'Value.Domain.Setting.Roots') (@(@((Get-DataCollection $Data 'Roots') | ForEach-Object { "$_" }) + @((Get-DataCollection $Data 'Paths') | ForEach-Object { "$_" })) -join ', ')
    )
    $entries = @((Get-DataCollection $Data 'Namespace') | ForEach-Object { $_ })
    if (-not $entries.Count) { return }
    New-Section -Title (Get-Text 'Title.Domain.Namespace') -Help (Get-Text 'Help.Domain.Namespace') -Row @(
        foreach ($entry in $entries) {
            foreach ($target in @((Get-DataCollection $entry 'Targets') | ForEach-Object { $_ })) {
                $row = [ordered]@{}
                $row[(Get-Text 'Column.Domain.Path')]   = "$(Get-DataProperty $entry 'Path')"
                $row[(Get-Text 'Column.Domain.Target')] = '\\{0}\{1}' -f "$(Get-DataProperty $target 'Server')", "$(Get-DataProperty $target 'Share')"
                $row[(Get-Text 'Column.Domain.TargetState')] = Get-DomainTargetStateText -State (Get-DomainTargetState -Target $target)
                $row[(Get-Text 'Column.NetworkDisruption.Address')] = @((Get-DataCollection $target 'Addresses') | ForEach-Object { "$_" }) -join ', '
                $row[(Get-Text 'Column.Domain.Smb')]    = Get-DomainPortText (Get-DataProperty $target 'SmbMs')
                [pscustomobject]$row
            }
        }
    )}