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. # # 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 } } # 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 # How long a question for a share may take. See the head of this file. $script:DomainShareTimeoutSeconds = 25 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 = "$Domain".Trim().TrimEnd('.').ToLowerInvariant() if (-not $full) { return } $short = ($full -split '\.')[0] $found = [System.Collections.Generic.List[string]]::new() foreach ($entry in @($Path | Where-Object { $_ })) { if ("$entry" -notmatch '^\\\\([^\\]+)\\([^\\]+)') { continue } $server = $Matches[1].ToLowerInvariant() $root = $Matches[2] 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 = "$Domain".Trim().TrimEnd('.').ToLowerInvariant() if (-not $full) { return } $short = ($full -split '\.')[0] $found = [System.Collections.Generic.List[string]]::new() foreach ($entry in @($Path | Where-Object { $_ })) { if ("$entry".Trim().TrimEnd('\') -notmatch '^\\\\([^\\]+)\\([^\\]+\\.+)$') { continue } $server = $Matches[1].ToLowerInvariant() $below = $Matches[2] 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. #> [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 { "$_" } | Sort-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-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 = @{}) $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('\')) Addresses = @() Roots = @() Paths = @() Asked = $false Controllers = @() } 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 { "$($_.NameTarget)".TrimEnd('.') } | 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 445 # 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 389 KerberosMs = Measure-TcpConnect -HostName $target -Port 88 SmbMs = $smb OffsetSeconds = Get-SntpOffset -Server $target -TimeoutMs 1500 Shares = $answers } }) [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 = "$(@((Get-DataCollection $Controller 'Addresses') | ForEach-Object { "$_" })[0])" 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') if (-not $silent.Count) { return New-Finding -Category Domain -Check $check -Severity OK -Value ($value -join ' | ') -Reference $table } $severity = 'WARN' if ($noLdap.Count -eq $Controller.Count) { $severity = 'FAIL' } 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' } 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 = @() 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) } } if ($share -eq 'SYSVOL') { if ($there.Count -lt $answers.Count) { $noSysvol += @($answers | Where-Object { -not $_.There }) } } elseif ($there.Count -and $there.Count -lt $answers.Count) { $uneven += $share } } $check = Get-Text 'Check.Domain.Shares' $value = $lines -join ' | ' $table = @(New-Reference -Section 'Title.Domain.Controllers') if (-not $noSysvol.Count -and -not $uneven.Count -and -not $slow.Count) { return New-Finding -Category Domain -Check $check -Severity OK -Value $value -Reference $table } $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' } 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 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')) { return New-UnavailableFinding -Category Domain -Check $check -Hint (Get-Text 'Hint.Domain.NotAsked') } $severity = Select-SituationText -Situation $Situation -Fact Form -Text @{ Notebook = 'INFO'; Unknown = 'WARN' } return 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') } New-DomainControllerFinding -Data $Data -Controller $controllers New-DomainShareFinding -Data $Data -Controller $controllers -Parameters $Parameters New-DomainNameFinding -Data $Data -Controller $controllers } 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 ', ') ) } |