Private/Kinds/Autodiscover.ps1
|
# The Autodiscover Kind: whether Outlook can find its mailbox. # # Every other Outlook service - MAPI, free/busy, the offline address book, shared # mailboxes - is found through Autodiscover, so a domain whose Autodiscover answers wrongly # is an Outlook that connects to the wrong place, or nowhere. This Kind asks the questions # Outlook asks (Microsoft, "Outlook 2016 implementation of Autodiscover"), per mail domain, # without anyone's credentials: # # - DNS: autodiscover.<domain> (CNAME chain and A), _autodiscover._tcp.<domain> (SRV) and # <domain> itself # - https://<domain>/autodiscover/autodiscover.xml and # https://autodiscover.<domain>/autodiscover/autodiscover.xml, each a POST with # Content-Length: 0 and an empty Bearer header, probed by the HttpsEndpoint Kind's # Invoke-HttpsEndpointProbe (status, challenges, certificate, timings; no redirect # followed, nothing sent that identifies anyone) # - http://autodiscover.<domain>/autodiscover/autodiscover.xml, redirect not followed # - Exchange Online's Autodiscover V2 answer for an address in the domain, which says # whether Exchange Online hosts it # # Observed on 2026-09-25 from this development machine (domain-joined, Microsoft 365 with # an Exchange hybrid), and different in three ways from what the research recorded the # day before: # # - Autodiscover V2 answers 200 {"Protocol":"Autodiscoverv1","Url":"https://outlook. # office365.com/autodiscover/autodiscover.xml"} for a mailbox in Exchange Online, and # 302 to https://autodiscover.<domain>/autodiscover/autodiscover.json/...&RedirectCount=1 # for a domain it does not host. But it also answers 302 for a hosted domain when the # address is not a mailbox: to outlook.office365.com again, with the address rewritten # into the tenant's onmicrosoft.com domain (or, for microsoft.com, a subdomain). So a # redirect is judged by where it points, not by being a redirect. # - In a hybrid, the answer is per mailbox, not per domain: the user's real address # answered 200 while a made-up address in the same domain answered 302 to the on-prem # server. So the real address is used whenever the machine names one in the domain, # and a made-up one ("probe@<domain>") only for a domain named in the Definition that # no address on the machine belongs to. The address is sent only to # outlook.office365.com, which is where Outlook sends it too. # - autodiscover.outlook.com, the CNAME target Microsoft requires for Exchange Online, # does not complete a TLS handshake at all (curl and SslStream alike); Exchange Online # domains are served by the plain-HTTP redirect to autodiscover-s.outlook.com and by # Outlook's direct Office 365 step. So a failed HTTPS probe of an autodiscover.<domain> # that CNAMEs into Microsoft is normal and not judged. # # Certificates. The ones that matter here are the Customer's: the root domain's and an # on-prem autodiscover.<domain>'s, which are graded at HealthChecker's 60/30 days. A # certificate on a name that CNAMEs into Microsoft is Microsoft's, short-lived by design # (100-199 days, renewed well ahead), and is never graded for expiry - nobody at the # Customer could renew it. # # Where the domains come from: the Domains parameter if set; else the user's UPN # (whoami /upn, which works unelevated whether the machine is domain-joined or # Entra-joined), the directory's mail attribute when domain-joined, and the Exchange # accounts of the user's Outlook profiles. A UPN is only a stand-in for a mail address, so # its domain is used only when neither of the others names one - a UPN suffix that is not # a mail domain would otherwise be reported as a broken mail domain. # # For later Kinds (-Observed): $Observed['Autodiscover'] is this Gatherer's data. # .Domains[] one entry per domain probed # .Domain the mail domain, lower case # .Sources where it came from: Parameter, Upn, DirectoryMail, # OutlookProfile # .AddressIsUser true when .Address is the user's own, false for probe@<domain> # .ExchangeOnline the V2 probe record (StatusCode, Location, Body, Error, ...) # .V2Url the Url out of the V2 answer's body, or $null # .ExchangeOnlinePin this Check's ExchangeOnline parameter as given (auto, yes, # no; 'auto' when unset), so a later Kind honours a Customer # pinned here # Read the mailbox location through ConvertTo-AutodiscoverMailboxLocation -Domain <entry> # -Parameters @{ ExchangeOnline = .ExchangeOnlinePin }: it returns ExchangeOnline, # OnPremises or Unknown and applies the pin, so no Kind classifies the answer a second way. # .Registry[] both AutoDiscover keys (research check 2): .Source Policy or # Preference, .Path, .Exists, .Values[] { Name, Value, Type }, # .Error. Read the values Outlook obeys through # Get-AutodiscoverOverride: the policy value wins. # .Scp the Autodiscover SCP search, $null when the machine is known # not to be domain-joined: .ConfigurationNamingContext, # .Entries[] { Name, DistinguishedName, Keywords[], # ServiceBindingInformation[], WhenChanged }, .Error # .CacheFiles[] Outlook's cached Autodiscover answers: { Folder, Name, # LastWriteTime, Length } # # What bends the lookup (research check 2). Microsoft names three leftovers that send # Outlook to the wrong place after a migration: registry values that skip or replace a step # of the search (both keys, "Policy" and "Non-Policy"; a Group Policy "AutoDiscover" setting # ticks all five Exclude boxes once enabled, KB 2612922), an SCP in Active Directory that # still advertises on-prem Exchange (KB 3012603, and in a hybrid KB 3137323), and a cached # answer. The overrides are judged against the mailbox location above: a value is only in # the way when it removes the step that mailbox needs. The SCP is read from the # Configuration partition, which every authenticated domain user can read (standard AD # behaviour, not re-sourced). No Microsoft threshold exists for a cached answer's age, so # the files are shown, not judged. # # Observed on 2026-09-25 on this hybrid machine: one SCP, keywords "Site=<site>" and # 77378F46-..., its serviceBindingInformation the on-prem https URL - so the Autodiscover # URL SCP carries the 77378F46 keyword, and a 67661d7F one is the pointer to another forest # (an LDAP URL). Only http(s) URLs are compared; pointers are shown. The preference key # existed without values; the policy key did not exist; no cached answer file existed. # Host names that are Microsoft's own Exchange Online front doors. A name ending in one of # these is Microsoft's, not the Customer's. $script:AutodiscoverMicrosoftSuffixes = @('outlook.com', 'office365.com', 'office.com', 'cloud.microsoft') # How much of the V2 answer's body is kept: it is under 100 bytes when it is JSON at all. $script:AutodiscoverBodyBytes = 4096 # The two keys Outlook reads its Autodiscover overrides from (Microsoft, "Outlook 2016 # implementation of Autodiscover"). Policy first: where both carry a value, the policy's is # the one Outlook obeys. $script:AutodiscoverRegistryKeys = @( [pscustomobject]@{ Source = 'Policy'; Path = 'HKCU\Software\Policies\Microsoft\Office\16.0\Outlook\AutoDiscover' } [pscustomobject]@{ Source = 'Preference'; Path = 'HKCU\Software\Microsoft\Office\16.0\Outlook\AutoDiscover' } ) # Which Exclude value skips which step of the search, by the step's evidence in the data. # Microsoft's archived KB "Unexpected Autodiscover behavior" names the values. $script:AutodiscoverExcludeStep = [ordered]@{ ExcludeScpLookup = 'Scp' ExcludeHttpsRootDomain = 'Root' ExcludeHttpsAutoDiscoverDomain = 'Autodiscover' ExcludeHttpRedirect = 'HttpRedirect' ExcludeSrvRecord = 'Srv' } # The Autodiscover SCP keywords (Microsoft, "Autodiscover service" and "Find Autodiscover # endpoints by using SCP lookup"): 77378F46 marks an SCP carrying an Autodiscover URL, # 67661d7F a pointer to another forest's SCPs. $script:AutodiscoverScpFilter = '(&(objectClass=serviceConnectionPoint)(|(keywords=67661d7F-8FC4-4fa7-BFAC-E1D7794C1F68)(keywords=77378F46-2C66-4aa9-A6A6-3E7A48B19596)))' function ConvertTo-AutodiscoverDomainName { <# .SYNOPSIS A mail domain as a lower-case DNS name, or $null when it is not one. Pure. .DESCRIPTION The name goes into URLs and DNS queries, so anything but letters, digits, hyphens and dots is refused rather than escaped: a Definition must not be able to write a path or a query into a request. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyString()][string]$Value) $name = "$Value".Trim().TrimEnd('.').ToLowerInvariant() if ($name -match '@') { $name = $name.Substring($name.LastIndexOf('@') + 1) } if ($name.Length -gt 253) { return $null } if ($name -notmatch '^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{0,61}[a-z0-9]$') { return $null } $name } function Get-AutodiscoverDomainEntry { <# .SYNOPSIS The entries of the Domains parameter, as a Definition may write them. Pure. .DESCRIPTION A list, or one string with commas or semicolons, because a hand-edited Definition writes both. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()]$Value) @($Value) | ForEach-Object { "$_" -split '[,;\s]+' } | Where-Object { $_ } } function Select-AutodiscoverDomain { <# .SYNOPSIS Which domains to probe, with where each came from and which address to ask Exchange Online about. Pure. .DESCRIPTION Candidates are the addresses the machine named ({ Address, Source }). Pinned are the domains the Definition named; when there are any, they are the whole list. The address for a domain is the user's own when one exists (hybrid answers per mailbox, see the file header), preferring the directory over the profile over the UPN, else probe@<domain>. #> [CmdletBinding()] [OutputType([psobject])] param( [AllowNull()][AllowEmptyCollection()]$Candidate = @(), [AllowNull()][AllowEmptyCollection()][string[]]$Pinned = @() ) $rank = @{ DirectoryMail = 0; OutlookProfile = 1; Upn = 2 } $known = @(foreach ($c in @($Candidate | Where-Object { $_ })) { $address = "$(Get-DataProperty $c 'Address')".Trim() $domain = ConvertTo-AutodiscoverDomainName $address if (-not $domain -or $address -notmatch '^[^@\s/?#%]+@[^@]+$') { continue } $source = "$(Get-DataProperty $c 'Source')" $order = 9 if ($rank.ContainsKey($source)) { $order = $rank[$source] } [pscustomobject]@{ Address = $address; Domain = $domain; Source = $source; Rank = $order } }) $domains = New-Object System.Collections.Generic.List[string] $pinnedNames = @(@($Pinned) | ForEach-Object { ConvertTo-AutodiscoverDomainName $_ } | Where-Object { $_ }) if ($pinnedNames.Count) { foreach ($d in $pinnedNames) { if (-not $domains.Contains($d)) { $domains.Add($d) } } } else { $mail = @($known | Where-Object { $_.Source -ne 'Upn' }) if (-not $mail.Count) { $mail = $known } foreach ($k in ($mail | Sort-Object Rank)) { if (-not $domains.Contains($k.Domain)) { $domains.Add($k.Domain) } } } foreach ($d in $domains) { $mine = @($known | Where-Object { $_.Domain -eq $d } | Sort-Object Rank) $sources = @() if ($pinnedNames.Count) { $sources += 'Parameter' } $sources += @($mine | ForEach-Object { $_.Source }) $address = 'probe@' + $d if ($mine.Count) { $address = $mine[0].Address } [pscustomobject]@{ Domain = $d Sources = @($sources | Select-Object -Unique) Address = $address AddressIsUser = [bool]$mine.Count } } } function ConvertFrom-AutodiscoverJson { <# .SYNOPSIS Protocol and Url out of an Autodiscover V2 answer's body, or $null. Pure. .DESCRIPTION The body is kept as it arrived, so a chunked answer still carries its chunk sizes; the JSON object is taken from the first brace to the last. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyString()][string]$Text) if (-not $Text) { return $null } $start = $Text.IndexOf('{') $end = $Text.LastIndexOf('}') if ($start -lt 0 -or $end -le $start) { return $null } try { $json = $Text.Substring($start, $end - $start + 1) | ConvertFrom-Json -ErrorAction Stop } catch { return $null } $url = "$(Get-DataProperty $json 'Url')" if (-not $url) { return $null } [pscustomobject]@{ Protocol = "$(Get-DataProperty $json 'Protocol')"; Url = $url } } function Test-AutodiscoverMicrosoftHost { <# .SYNOPSIS Whether a host name (or a URL's host) is one of Exchange Online's own. Pure. #> [CmdletBinding()] [OutputType([bool])] param([AllowNull()][AllowEmptyString()][string]$Name) $hostName = "$Name".Trim().TrimEnd('.').ToLowerInvariant() $uri = $null if ([uri]::TryCreate($hostName, [UriKind]::Absolute, [ref]$uri) -and $uri.Host) { $hostName = $uri.Host } if (-not $hostName) { return $false } foreach ($suffix in $script:AutodiscoverMicrosoftSuffixes) { if ($hostName -eq $suffix -or $hostName.EndsWith('.' + $suffix)) { return $true } } $false } function Get-AutodiscoverExchangeOnlineAnswer { <# .SYNOPSIS What Exchange Online's Autodiscover V2 said about a domain's address: Yes, No or Unknown. Pure. .DESCRIPTION 200 with a Url on Exchange Online, or a redirect that stays on Exchange Online (the address rewritten within the tenant), is Yes. A redirect anywhere else - to autodiscover.<domain> - or a 200 whose Url points on-prem (a hybrid mailbox) is No. Everything else, a failed request above all, is Unknown. Observed 2026-09-25; see the file header. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()]$Domain) $probe = Get-DataProperty $Domain 'ExchangeOnline' $status = ConvertTo-Number (Get-DataProperty $probe 'StatusCode') if (-not $probe -or (Get-DataProperty $probe 'Error') -or $null -eq $status) { return 'Unknown' } if ($status -eq 200) { $url = "$(Get-DataProperty $Domain 'V2Url')" if (-not $url) { return 'Unknown' } if (Test-AutodiscoverMicrosoftHost $url) { return 'Yes' } return 'No' } if ($status -ge 300 -and $status -lt 400) { $location = "$(Get-DataProperty $probe 'Location')" if (-not $location) { return 'Unknown' } if (Test-AutodiscoverMicrosoftHost $location) { return 'Yes' } return 'No' } 'Unknown' } function ConvertTo-AutodiscoverMailboxLocation { <# .SYNOPSIS Where a domain's mailbox is, as the Judge and later Kinds take it: ExchangeOnline, OnPremises or Unknown. Pure. .DESCRIPTION The ExchangeOnline parameter (auto, yes, no) pins it for a Customer whose answer should not be taken from Microsoft - an on-prem or hybrid Customer who knows better. Anything but yes or no is auto. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()]$Domain, [hashtable]$Parameters = @{}) $pin = "$(Get-Parameter $Parameters 'ExchangeOnline' 'auto')".Trim().ToLowerInvariant() if ($pin -eq 'yes') { return 'ExchangeOnline' } if ($pin -eq 'no') { return 'OnPremises' } switch (Get-AutodiscoverExchangeOnlineAnswer -Domain $Domain) { 'Yes' { return 'ExchangeOnline' } 'No' { return 'OnPremises' } default { return 'Unknown' } } } function Get-AutodiscoverData { [CmdletBinding()] [OutputType([psobject])] param([hashtable]$Parameters = @{}) $pinned = @(Get-AutodiscoverDomainEntry (Get-Parameter $Parameters 'Domains' @())) $isDomainJoined = $null try { $isDomainJoined = [bool](Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop).PartOfDomain } catch { } $candidates = New-Object System.Collections.Generic.List[psobject] $sourceErrors = New-Object System.Collections.Generic.List[psobject] try { $upn = @(& whoami.exe /upn 2>$null) if ($LASTEXITCODE -eq 0 -and "$($upn[0])" -match '@') { $candidates.Add([pscustomobject]@{ Address = "$($upn[0])".Trim(); Source = 'Upn' }) } } catch { $sourceErrors.Add([pscustomobject]@{ Source = 'Upn'; Error = $_.Exception.Message }) } if ($isDomainJoined) { try { # By SID rather than by name: the SID is the one identity that cannot be # ambiguous across a forest. $searcher = New-Object System.DirectoryServices.DirectorySearcher $searcher.Filter = '(objectSid={0})' -f [Security.Principal.WindowsIdentity]::GetCurrent().User.Value $searcher.ClientTimeout = [timespan]::FromSeconds(10) $searcher.ServerTimeLimit = [timespan]::FromSeconds(10) $null = $searcher.PropertiesToLoad.Add('mail') $found = $searcher.FindOne() if ($found) { foreach ($mail in @($found.Properties['mail'])) { if ($mail) { $candidates.Add([pscustomobject]@{ Address = "$mail"; Source = 'DirectoryMail' }) } } } } catch { $sourceErrors.Add([pscustomobject]@{ Source = 'DirectoryMail'; Error = $_.Exception.GetBaseException().Message }) } } try { foreach ($address in (Get-AutodiscoverProfileAddress)) { $candidates.Add([pscustomobject]@{ Address = $address; Source = 'OutlookProfile' }) } } catch { $sourceErrors.Add([pscustomobject]@{ Source = 'OutlookProfile'; Error = $_.Exception.Message }) } $selected = @(Select-AutodiscoverDomain -Candidate $candidates -Pinned $pinned) $domains = @(foreach ($entry in $selected) { $d = $entry.Domain $v2 = Invoke-HttpsEndpointProbe -Target ([pscustomobject]@{ Key = 'V2'; Method = 'GET'; BearerProbe = $false; ReadBodyBytes = $script:AutodiscoverBodyBytes Url = 'https://outlook.office365.com/autodiscover/autodiscover.json/v1.0/{0}?Protocol=Autodiscoverv1' -f [uri]::EscapeDataString($entry.Address).Replace('%40', '@') }) $parsed = ConvertFrom-AutodiscoverJson -Text $v2.Body [pscustomobject]@{ Domain = $d Sources = $entry.Sources Address = $entry.Address AddressIsUser = $entry.AddressIsUser Dns = [pscustomobject]@{ Autodiscover = Resolve-AutodiscoverName -Name ('autodiscover.' + $d) -Type 'A' Srv = Resolve-AutodiscoverName -Name ('_autodiscover._tcp.' + $d) -Type 'SRV' Root = Resolve-AutodiscoverName -Name $d -Type 'A' } Root = Invoke-HttpsEndpointProbe -Target ([pscustomobject]@{ Key = 'Root'; Method = 'POST'; BearerProbe = $true Url = 'https://{0}/autodiscover/autodiscover.xml' -f $d }) Autodiscover = Invoke-HttpsEndpointProbe -Target ([pscustomobject]@{ Key = 'Autodiscover'; Method = 'POST'; BearerProbe = $true Url = 'https://autodiscover.{0}/autodiscover/autodiscover.xml' -f $d }) HttpRedirect = Invoke-HttpsEndpointProbe -Target ([pscustomobject]@{ Key = 'HttpRedirect'; Method = 'GET'; BearerProbe = $false Url = 'http://autodiscover.{0}/autodiscover/autodiscover.xml' -f $d }) ExchangeOnline = $v2 V2Url = $(if ($parsed) { $parsed.Url } else { $null }) } }) [pscustomobject]@{ PSTypeName = 'Gutcheck.Data.Autodiscover' # What certificate lifetimes are counted from, so the Judge never asks the clock. GatheredAt = Get-Date IsDomainJoined = $isDomainJoined Candidates = @($candidates) SourceErrors = @($sourceErrors) Domains = $domains Registry = @(Get-AutodiscoverRegistry) # Searched unless the machine is known not to be joined: when CIM would not say, # the search itself finds out, and its error is the evidence. Scp = $(if ($isDomainJoined -ne $false) { Get-AutodiscoverScp } else { $null }) CacheFiles = @(Get-AutodiscoverCacheFile) # The pin as given, for later Kinds; ConvertTo-AutodiscoverMailboxLocation reads it. ExchangeOnlinePin = "$(Get-Parameter $Parameters 'ExchangeOnline' 'auto')" } } function Get-AutodiscoverRegistry { <# .SYNOPSIS Both AutoDiscover keys, every value as it is stored. Never throws. .DESCRIPTION Every value is kept, known or not: a domain-named string value is the local XML file PreferLocalXML points at, and a value this Kind does not know yet is still evidence for second level. #> [CmdletBinding()] [OutputType([psobject])] param() foreach ($key in $script:AutodiscoverRegistryKeys) { $record = [ordered]@{ Source = $key.Source; Path = $key.Path; Exists = $false; Values = @(); Error = $null } try { $item = Get-Item -LiteralPath ('Registry::HKEY_CURRENT_USER' + $key.Path.Substring(4)) -ErrorAction Stop $record.Exists = $true $record.Values = @(foreach ($name in $item.GetValueNames()) { if (-not $name) { continue } $value = $item.GetValue($name, $null, 'DoNotExpandEnvironmentNames') if ($value -is [byte[]]) { $value = [BitConverter]::ToString($value) } if ($value -is [string[]]) { $value = $value -join '; ' } [pscustomobject]@{ Name = $name; Value = $value; Type = "$($item.GetValueKind($name))" } }) } catch [System.Management.Automation.ItemNotFoundException] { } catch { $record.Error = $_.Exception.Message } [pscustomobject]$record } } function Get-AutodiscoverScp { <# .SYNOPSIS The Autodiscover SCPs in the forest's Configuration partition. Never throws. .DESCRIPTION The same search Outlook makes (research check 2): RootDSE names the partition, and the SCPs are found by keyword. Runs as the user; no elevation is needed to read it. #> [CmdletBinding()] [OutputType([psobject])] param() $record = [ordered]@{ ConfigurationNamingContext = $null; Entries = @(); Error = $null } try { $rootDse = New-Object System.DirectoryServices.DirectoryEntry('LDAP://RootDSE') $configuration = "$($rootDse.Properties['configurationNamingContext'].Value)" if (-not $configuration) { throw 'RootDSE names no configurationNamingContext' } $record.ConfigurationNamingContext = $configuration $searcher = New-Object System.DirectoryServices.DirectorySearcher $searcher.SearchRoot = New-Object System.DirectoryServices.DirectoryEntry('LDAP://' + $configuration.Replace('/', '\/')) $searcher.Filter = $script:AutodiscoverScpFilter $searcher.PageSize = 200 $searcher.ClientTimeout = [timespan]::FromSeconds(10) $searcher.ServerTimeLimit = [timespan]::FromSeconds(10) foreach ($p in 'cn', 'distinguishedName', 'keywords', 'serviceBindingInformation', 'whenChanged') { $null = $searcher.PropertiesToLoad.Add($p) } $found = $searcher.FindAll() try { $record.Entries = @(foreach ($r in $found) { # whenChanged is not replicated: each domain controller keeps its own, so # two Runs can show different dates for the same SCP (observed: 2022 and # 2024 a minute apart). It dates the SCP roughly, no more. $when = @($r.Properties['whenchanged']) [pscustomobject]@{ Name = "$(@($r.Properties['cn'])[0])" DistinguishedName = "$(@($r.Properties['distinguishedname'])[0])" Keywords = @($r.Properties['keywords'] | ForEach-Object { "$_" }) ServiceBindingInformation = @($r.Properties['servicebindinginformation'] | ForEach-Object { "$_" }) WhenChanged = $(if ($when.Count -and $when[0] -is [datetime]) { $when[0] } else { $null }) } }) } finally { $found.Dispose() } } catch { $record.Error = $_.Exception.GetBaseException().Message } [pscustomobject]$record } function Get-AutodiscoverCacheFile { <# .SYNOPSIS Outlook's cached Autodiscover answers: name, date and size, never the content. .DESCRIPTION The files Microsoft says to delete when resetting Autodiscover: *Autodiscover.xml in the Outlook folder, and the XML and JSON files in its 16 subfolder. Their names carry mail addresses, which is what makes them worth showing. #> [CmdletBinding()] [OutputType([psobject])] param() if (-not $env:LOCALAPPDATA) { return } $outlook = Join-Path $env:LOCALAPPDATA 'Microsoft\Outlook' foreach ($place in @( @{ Folder = 'Outlook'; Path = $outlook; Filter = '*Autodiscover.xml' } @{ Folder = 'Outlook\16'; Path = (Join-Path $outlook '16'); Filter = '*.xml' } @{ Folder = 'Outlook\16'; Path = (Join-Path $outlook '16'); Filter = '*.json' })) { if (-not (Test-Path -LiteralPath $place.Path)) { continue } foreach ($file in (Get-ChildItem -LiteralPath $place.Path -Filter $place.Filter -File -ErrorAction SilentlyContinue)) { [pscustomobject]@{ Folder = $place.Folder; Name = $file.Name; LastWriteTime = $file.LastWriteTime; Length = $file.Length } } } } function Get-AutodiscoverProfileAddress { <# .SYNOPSIS The addresses of the Exchange accounts in the user's Outlook profiles. .DESCRIPTION Exchange accounts only (Service Name MSEMS): an IMAP or POP account's domain has no Autodiscover to find, and probing it would report a working mailbox as broken. Values are strings on current builds and UTF-16 bytes on older ones. #> [CmdletBinding()] [OutputType([string])] param() $office = 'HKCU:\Software\Microsoft\Office' if (-not (Test-Path $office)) { return } foreach ($version in (Get-ChildItem $office -ErrorAction SilentlyContinue)) { $profiles = Join-Path $version.PSPath 'Outlook\Profiles' if (-not (Test-Path $profiles)) { continue } foreach ($outlookProfile in (Get-ChildItem $profiles -ErrorAction SilentlyContinue)) { $accounts = Join-Path $outlookProfile.PSPath '9375CFF0413111d3B88A00104B2A6676' if (-not (Test-Path $accounts)) { continue } foreach ($account in (Get-ChildItem $accounts -ErrorAction SilentlyContinue)) { $read = { param($Name) $value = $account.GetValue($Name) if ($value -is [byte[]]) { return [Text.Encoding]::Unicode.GetString($value).TrimEnd([char]0) } "$value" } if ((& $read 'Service Name') -ne 'MSEMS') { continue } $name = & $read 'Account Name' if ($name -match '@') { $name.Trim() } } } } } function Resolve-AutodiscoverName { <# .SYNOPSIS One DNS question and its raw answer. Never throws. .DESCRIPTION Resolve-DnsName where it exists, because only it gives the CNAME chain and SRV. The error is kept by its id (DNS_ERROR_RCODE_NAME_ERROR, ...) as well as its message, because the message is in the machine's language. Without Resolve-DnsName, an A question falls back to .NET, which gives addresses only. #> [CmdletBinding()] [OutputType([psobject])] param([Parameter(Mandatory)][string]$Name, [Parameter(Mandatory)][string]$Type) $record = [ordered]@{ Name = $Name; Type = $Type; Records = @(); ErrorId = $null; Error = $null } if (Get-Command -Name Resolve-DnsName -ErrorAction SilentlyContinue) { try { $answers = @(Resolve-DnsName -Name $Name -Type $Type -DnsOnly -QuickTimeout -ErrorAction Stop) $record.Records = @(foreach ($a in $answers) { if ("$($a.Section)" -and "$($a.Section)" -ne 'Answer') { continue } $data = switch ("$($a.Type)") { 'CNAME' { $a.NameHost } 'A' { $a.IPAddress } 'AAAA' { $a.IPAddress } 'SRV' { '{0}:{1} priority {2} weight {3}' -f $a.NameTarget, $a.Port, $a.Priority, $a.Weight } default { $null } } if ($null -eq $data) { continue } [pscustomobject]@{ Name = $a.Name; Type = "$($a.Type)"; Data = "$data" } }) } catch { $record.ErrorId = ("$($_.FullyQualifiedErrorId)" -split ',')[0] $record.Error = $_.Exception.Message } } elseif ($Type -eq 'A') { try { $record.Records = @([System.Net.Dns]::GetHostAddresses($Name) | Where-Object { $_.AddressFamily -eq 'InterNetwork' } | ForEach-Object { [pscustomobject]@{ Name = $Name; Type = 'A'; Data = "$_" } }) } catch { $record.ErrorId = 'DotNetLookupFailed' $record.Error = $_.Exception.GetBaseException().Message } } else { $record.ErrorId = 'Unsupported' $record.Error = 'Resolve-DnsName is not available on this machine' } [pscustomobject]$record } function Test-AutodiscoverExchangeAnswer { <# .SYNOPSIS Whether a probe was answered the way Exchange answers an anonymous request: 401 with a challenge Exchange offers. Pure. .DESCRIPTION Negotiate and NTLM on-prem, Bearer with hybrid modern authentication or in Exchange Online, Basic where it is still on. Microsoft: an answer without WWW-Authenticate means a device in front of Exchange responded. #> [CmdletBinding()] [OutputType([bool])] param([AllowNull()]$Probe) if (-not $Probe -or (Get-DataProperty $Probe 'Error')) { return $false } if ((ConvertTo-Number (Get-DataProperty $Probe 'StatusCode')) -ne 401) { return $false } $schemes = @((Get-DataCollection $Probe 'WwwAuthenticate') | ForEach-Object { ConvertFrom-WwwAuthenticate -Value $_ } | ForEach-Object { $_.Scheme }) @($schemes | Where-Object { @('Negotiate', 'NTLM', 'Bearer', 'Basic') -contains $_ }).Count -gt 0 } function Get-AutodiscoverChallengeText { <# .SYNOPSIS The challenge schemes a probe was offered, comma-separated. Pure. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()]$Probe) (@((Get-DataCollection $Probe 'WwwAuthenticate') | ForEach-Object { ConvertFrom-WwwAuthenticate -Value $_ } | ForEach-Object { $_.Scheme } | Select-Object -Unique) -join ', ') } function ConvertTo-AutodiscoverFinding { [CmdletBinding()] [OutputType([psobject])] param( [AllowNull()]$Data, [hashtable]$Parameters = @{} ) $pin = "$(Get-Parameter $Parameters 'ExchangeOnline' 'auto')".Trim().ToLowerInvariant() if (@('auto', 'yes', 'no') -notcontains $pin) { New-Finding -Category Apps -Check (Get-Text 'Check.Autodiscover.Autodiscover') -Severity INFO ` -Value ((Get-Text 'Value.Autodiscover.InvalidPin') -f $pin) ` -Hint (Get-Text 'Hint.Autodiscover.InvalidPin') } foreach ($entry in (Get-AutodiscoverDomainEntry (Get-Parameter $Parameters 'Domains' @()))) { if (-not (ConvertTo-AutodiscoverDomainName $entry)) { New-Finding -Category Apps -Check (Get-Text 'Check.Autodiscover.Autodiscover') -Severity INFO ` -Value ((Get-Text 'Value.Autodiscover.InvalidDomain') -f $entry) ` -Hint (Get-Text 'Hint.Autodiscover.InvalidDomain') } } # Machine-wide, so judged whether or not a domain was found. New-AutodiscoverOverrideFinding -Data $Data -Parameters $Parameters New-AutodiscoverScpFinding -Data $Data -Parameters $Parameters $domains = Get-DataCollection $Data 'Domains' if (-not $domains.Count) { return New-Finding -Category Apps -Check (Get-Text 'Check.Autodiscover.Autodiscover') -Severity INFO ` -Value (Get-Text 'Value.Autodiscover.NoDomain') ` -Hint (Get-Text 'Hint.Autodiscover.NoDomain') } $gatheredAt = Get-DataProperty $Data 'GatheredAt' foreach ($domain in $domains) { New-AutodiscoverLocationFinding -Domain $domain -Parameters $Parameters New-AutodiscoverVerdict -Domain $domain -GatheredAt $gatheredAt -Parameters $Parameters -Data $Data New-AutodiscoverRootFinding -Domain $domain } } function New-AutodiscoverLocationFinding { <# .SYNOPSIS The INFO Finding saying whether the domain's mailbox is in Exchange Online. Pure. #> [CmdletBinding()] [OutputType([psobject])] param([Parameter(Mandatory)]$Domain, [hashtable]$Parameters = @{}) $name = "$(Get-DataProperty $Domain 'Domain')" $answer = Get-AutodiscoverExchangeOnlineAnswer -Domain $Domain $location = ConvertTo-AutodiscoverMailboxLocation -Domain $Domain -Parameters $Parameters $probe = Get-DataProperty $Domain 'ExchangeOnline' $said = switch ($answer) { 'Yes' { Get-Text 'Value.Autodiscover.Yes' } 'No' { Get-Text 'Value.Autodiscover.No' } default { Get-Text 'Value.Autodiscover.Unknown' } } $failure = Get-DataProperty $probe 'Error' $status = Get-DataProperty $probe 'StatusCode' $evidence = if ($failure) { (Get-Text 'Value.Autodiscover.V2Failed') -f (Get-DataProperty $failure 'Message') } elseif (Get-DataProperty $probe 'Location') { (Get-Text 'Value.Autodiscover.V2Redirect') -f $status, ([uri](Get-DataProperty $probe 'Location')).Host } else { (Get-Text 'Value.Autodiscover.V2Status') -f $status } $value = (Get-Text 'Value.Autodiscover.Location') -f $said, $evidence $pin = "$(Get-Parameter $Parameters 'ExchangeOnline' 'auto')".Trim().ToLowerInvariant() if ($pin -eq 'yes' -or $pin -eq 'no') { $pinned = Get-Text 'Value.Autodiscover.No' if ($location -eq 'ExchangeOnline') { $pinned = Get-Text 'Value.Autodiscover.Yes' } $value = (Get-Text 'Value.Autodiscover.Pinned') -f $pinned, $value } # A made-up address answers for the domain, and in a hybrid the domain is split. if ($answer -eq 'No' -and -not (Get-DataProperty $Domain 'AddressIsUser')) { $value += Get-Text 'Value.Autodiscover.ProbeAddress' } $hint = switch ($location) { 'ExchangeOnline' { Get-Text 'Hint.Autodiscover.InExchangeOnline' } 'OnPremises' { Get-Text 'Hint.Autodiscover.NotInExchangeOnline' } default { Get-Text 'Hint.Autodiscover.LocationUnknown' } } New-Finding -Category Apps -Check ((Get-Text 'Check.Autodiscover.Mailbox') -f $name) -Severity INFO -Value $value -Hint $hint } function New-AutodiscoverVerdict { <# .SYNOPSIS The one endpoint-health Finding for one domain: whether Outlook's Autodiscover lookup for it ends at a working Exchange. Pure. .DESCRIPTION Judged against where the mailbox is. For Exchange Online, Microsoft requires autodiscover.<domain> to CNAME to autodiscover.outlook.com; missing or pointing elsewhere, Outlook still gets there through its direct Office 365 step, but other clients and Outlook's other steps do not (WARN); pointing at something that answers but is not Exchange is the old-server-after-migration case (FAIL). An autodiscover.<domain> that answers as Exchange with a valid certificate is fine in either case, which is how a hybrid is set up. On-prem (and where the mailbox is unknown), the domain is judged by the first step of Outlook's lookup that works, in Outlook's order: the SCP, the root domain, autodiscover.<domain>, the HTTP redirect, SRV (Microsoft, "Outlook 2016 implementation of Autodiscover"; research check 1). Outlook stops at the first step that answers (Inference, research check 1), so a later step that is broken - a wildcard DNS entry sending autodiscover.<domain> to the website - is INFO when an earlier one works, and FAIL only when none does. The certificate graded is the one of the step Outlook uses: autodiscover.<domain>'s must be valid, because Outlook does not silence its errors (step 7), and is graded for lifetime like any Customer certificate; the root domain's is not, because Outlook silences its errors (step 6). The SCP's URL gets no probe of its own: a probe of the same URL for any domain is its answer, and an SCP nobody asked counts as working, untested, and says so. A step the registry tells Outlook to skip does not count, except autodiscover.<domain> itself, whose exclusion the overrides Finding reports. #> [CmdletBinding()] [OutputType([psobject])] param( [Parameter(Mandatory)]$Domain, [AllowNull()]$GatheredAt, [hashtable]$Parameters = @{}, # The whole data, for what is machine-wide: the SCP and the registry overrides. [AllowNull()]$Data ) # Microsoft's HealthChecker grades an Exchange certificate green at 60 days or more, # yellow at 30-59 and red under 30 (CSS-Exchange CertificateCheck). $warnDays = Get-Parameter $Parameters 'CertificateWarnDays' 60 $failDays = Get-Parameter $Parameters 'CertificateFailDays' 30 # Unsourced: no Microsoft threshold exists; a first guess to tune. Judged on the # connection setup, not the whole request, for the reason the HttpsEndpoint Kind # gives (Exchange takes its own time to answer), and so borrowed from its LatencyWarnMs. $responseWarn = Get-Parameter $Parameters 'ResponseWarnMs' 150 $name = "$(Get-DataProperty $Domain 'Domain')" $check = (Get-Text 'Check.Autodiscover.Domain') -f $name $location = ConvertTo-AutodiscoverMailboxLocation -Domain $Domain -Parameters $Parameters $online = $location -eq 'ExchangeOnline' $adName = 'autodiscover.' + $name $dns = Get-DataProperty (Get-DataProperty $Domain 'Dns') 'Autodiscover' $records = Get-DataCollection $dns 'Records' $cnames = @($records | Where-Object { $_.Type -eq 'CNAME' } | ForEach-Object { "$($_.Data)" }) $resolved = @($records | Where-Object { $_.Type -eq 'A' -or $_.Type -eq 'AAAA' }).Count -gt 0 $toMicrosoft = @($cnames | Where-Object { Test-AutodiscoverMicrosoftHost $_ }).Count -gt 0 if ($null -eq $dns) { return New-UnavailableFinding -Category Apps -Check $check -Hint (Get-Text 'Hint.Autodiscover.NotRecorded') } if ($toMicrosoft -and $location -ne 'OnPremises') { return New-Finding -Category Apps -Check $check -Severity OK ` -Value ((Get-Text 'Value.Autodiscover.PointsToMicrosoft') -f $adName, $cnames[0]) } # What autodiscover.<domain> itself says: $null when the name does not resolve, else # the Finding it earns on its own. $why = $null $adProbe = Get-DataProperty $Domain 'Autodiscover' $adFinding = $null if ($toMicrosoft) { $adFinding = New-Finding -Category Apps -Check $check -Severity FAIL ` -Value ((Get-Text 'Value.Autodiscover.PointsToMicrosoft') -f $adName, $cnames[0]) ` -Hint (Get-Text 'Hint.Autodiscover.PointsToMicrosoftButOnPremises') } elseif ($resolved) { $adFinding = New-AutodiscoverEndpointFinding -Probe $adProbe -Check $check -Label $adName -Online $online ` -GatheredAt $GatheredAt -WarnDays $warnDays -FailDays $failDays -ResponseWarnMs $responseWarn } else { # "Does not exist" and "has no address" are the same news; a lookup that failed # is not, and its message says why. $why = (Get-Text 'Value.Autodiscover.NotResolving') -f $adName $errorId = "$(Get-DataProperty $dns 'ErrorId')" if ($errorId -and $errorId -ne 'DNS_ERROR_RCODE_NAME_ERROR' -and $errorId -ne 'DNS_INFO_NO_RECORDS') { $why = (Get-Text 'Value.Autodiscover.LookupFailed') -f $adName, (Get-DataProperty $dns 'Error') } } if ($online) { if ($adFinding) { return $adFinding } return New-Finding -Category Apps -Check $check -Severity WARN -Value $why ` -Hint (Get-Text 'Hint.Autodiscover.MissingCname') } # Which step of Outlook's lookup works, as far as the data shows. $override = @(Get-AutodiscoverOverride -Data $Data) $rootProbe = Get-DataProperty $Domain 'Root' $adWorks = $resolved -and -not $toMicrosoft -and (Test-AutodiscoverExchangeAnswer $adProbe) $rootWorks = -not (Test-AutodiscoverOverrideOn $override 'ExcludeHttpsRootDomain') -and (Test-AutodiscoverExchangeAnswer $rootProbe) $redirect = Get-DataProperty $Domain 'HttpRedirect' $redirectStatus = ConvertTo-Number (Get-DataProperty $redirect 'StatusCode') $redirectTo = "$(Get-DataProperty $redirect 'Location')" $redirectWorks = -not (Test-AutodiscoverOverrideOn $override 'ExcludeHttpRedirect') -and $null -ne $redirectStatus -and $redirectStatus -ge 300 -and $redirectStatus -lt 400 -and $redirectTo -match '^https://' -and -not (Test-AutodiscoverMicrosoftHost $redirectTo) $srv = @((Get-DataCollection (Get-DataProperty (Get-DataProperty $Domain 'Dns') 'Srv') 'Records') | Where-Object { $_.Type -eq 'SRV' }) $srvWorks = -not (Test-AutodiscoverOverrideOn $override 'ExcludeSrvRecord') -and $srv.Count -gt 0 # The SCP. Its URL is not probed itself; but when it names, on 443, a host a probe of # any domain asked at the same path, that probe is its answer - an SCP naming # autodiscover.<domain> works exactly when that step does (as on this machine). An SCP # whose answer is known not to be Exchange is a step that fails, and Outlook moves on. # Any other on-prem URL is taken as the step Outlook tries first, untested. $asked = @{} $all = New-Object System.Collections.Generic.List[object] $all.Add($Domain) foreach ($d in (Get-DataCollection $Data 'Domains')) { $all.Add($d) } foreach ($d in $all) { $dn = "$(Get-DataProperty $d 'Domain')" if (-not $dn) { continue } if (-not $asked.ContainsKey('autodiscover.' + $dn)) { $asked['autodiscover.' + $dn] = Get-DataProperty $d 'Autodiscover' } if (-not $asked.ContainsKey($dn)) { $asked[$dn] = Get-DataProperty $d 'Root' } } $scpSkipped = $false $scpAsked = $null $scpUntested = $null $scpUrls = @(Get-AutodiscoverScpUrl -Data $Data | Where-Object { $_.OnPremises }) if ($scpUrls.Count -and (Test-AutodiscoverOverrideOn $override 'ExcludeScpLookup')) { $scpSkipped = $true } elseif ($scpUrls.Count) { foreach ($u in $scpUrls) { $uri = $null if (-not [uri]::TryCreate($u.Url, [UriKind]::Absolute, [ref]$uri)) { continue } $scpHost = $uri.Host.ToLowerInvariant() $probe = $null if ($uri.Scheme -eq 'https' -and $uri.Port -eq 443 -and $uri.AbsolutePath -ieq '/autodiscover/autodiscover.xml' -and $asked.ContainsKey($scpHost)) { $probe = $asked[$scpHost] } if ($probe) { if (-not $scpAsked -and (Test-AutodiscoverExchangeAnswer $probe)) { $scpAsked = [pscustomobject]@{ Url = $u.Url; Probe = $probe; IsAutodiscover = $scpHost -eq $adName } } } elseif (-not $scpUntested) { $scpUntested = $u.Url } } } # An SCP a probe answered as Exchange outranks one nobody asked: it is known to work. $scpFinding = $null if ($scpAsked) { # The same server Outlook would ask next: autodiscover.<domain>'s own grading says it. if ($scpAsked.IsAutodiscover) { return $adFinding } $scpFinding = New-AutodiscoverEndpointFinding -Probe $scpAsked.Probe -Check $check ` -Label ((Get-Text 'Value.Autodiscover.ScpLabel') -f $scpAsked.Url) -Online $false ` -GatheredAt $GatheredAt -WarnDays $warnDays -FailDays $failDays -ResponseWarnMs $responseWarn # A certificate or speed problem on the server Outlook uses is this domain's problem. if ($scpFinding.Severity -ne 'OK') { return $scpFinding } } # The verdict without an untested SCP. $broken = $why if ($adFinding) { $broken = $adFinding.Value } $rest = if ($rootWorks) { $step = (Get-Text 'Value.Autodiscover.StepRoot') -f $name if (-not $adFinding) { New-Finding -Category Apps -Check $check -Severity OK -Value ((Get-Text 'Value.Autodiscover.ViaRoot') -f $why, $name) } elseif ($adFinding.Severity -eq 'OK') { New-Finding -Category Apps -Check $check -Severity OK -Value ((Get-Text 'Value.Autodiscover.UsesEarlier') -f $adFinding.Value, $step) } else { New-Finding -Category Apps -Check $check -Severity INFO ` -Value ((Get-Text 'Value.Autodiscover.UsesEarlier') -f $adFinding.Value, $step) -Hint (Get-Text 'Hint.Autodiscover.LaterStepBroken') } } elseif ($adWorks) { $adFinding } elseif ($redirectWorks) { New-Finding -Category Apps -Check $check -Severity INFO ` -Value ((Get-Text 'Value.Autodiscover.UsesLater') -f $broken, ((Get-Text 'Value.Autodiscover.StepHttpRedirect') -f $redirectTo)) ` -Hint (Get-Text 'Hint.Autodiscover.HttpRedirectOnly') } elseif ($srvWorks) { New-Finding -Category Apps -Check $check -Severity INFO ` -Value ((Get-Text 'Value.Autodiscover.SrvOnly') -f $broken, $srv[0].Data) ` -Hint (Get-Text 'Hint.Autodiscover.SrvOnly') } elseif ($adFinding) { $adFinding } else { New-Finding -Category Apps -Check $check -Severity FAIL ` -Value ((Get-Text 'Value.Autodiscover.NothingAnswers') -f $why) ` -Hint (Get-Text 'Hint.Autodiscover.NothingAnswers') } if ($scpFinding -or $scpUntested) { $step = if ($scpFinding) { $scpFinding.Value } else { (Get-Text 'Value.Autodiscover.StepScp') -f $scpUntested } $value = (Get-Text 'Value.Autodiscover.UsesEarlier') -f $rest.Value, $step if ($rest.Severity -eq 'OK') { return New-Finding -Category Apps -Check $check -Severity OK -Value $value } $hint = Get-Text 'Hint.Autodiscover.LaterStepBroken' if (-not ($rootWorks -or $adWorks -or $redirectWorks -or $srvWorks)) { $hint = Get-Text 'Hint.Autodiscover.ScpOnly' } elseif ($rest.Severity -eq 'INFO') { $hint = $rest.Hint } return New-Finding -Category Apps -Check $check -Severity INFO -Value $value -Hint $hint } if ($scpSkipped -and $rest.Severity -eq 'FAIL') { return New-Finding -Category Apps -Check $check -Severity FAIL ` -Value ($rest.Value + (Get-Text 'Value.Autodiscover.ScpSkipped')) -Hint $rest.Hint } $rest } function New-AutodiscoverEndpointFinding { <# .SYNOPSIS What https://autodiscover.<domain>/ earns on its own: whether Exchange answers, its certificate and how fast it connects. Pure. .DESCRIPTION The name resolves to something that is not Microsoft's. The thresholds are read by New-AutodiscoverVerdict and handed in. #> [CmdletBinding()] [OutputType([psobject])] param( [AllowNull()]$Probe, [Parameter(Mandatory)][string]$Check, [Parameter(Mandatory)][string]$Label, [bool]$Online, [AllowNull()]$GatheredAt, [double]$WarnDays, [double]$FailDays, [double]$ResponseWarnMs ) $failure = Get-DataProperty $Probe 'Error' if (-not $Probe) { return New-UnavailableFinding -Category Apps -Check $Check -Hint (Get-Text 'Hint.Autodiscover.NotRecorded') } if ($failure) { $stage = switch ("$(Get-DataProperty $Probe 'Stage')") { 'Dns' { Get-Text 'Value.HttpsEndpoint.Stage.Dns' } 'Connect' { Get-Text 'Value.HttpsEndpoint.Stage.Connect' } 'Proxy' { Get-Text 'Value.HttpsEndpoint.Stage.Proxy' } 'Tls' { Get-Text 'Value.HttpsEndpoint.Stage.Tls' } default { Get-Text 'Value.HttpsEndpoint.Stage.Http' } } $value = (Get-Text 'Value.Autodiscover.NoAnswer') -f $Label, $stage, (Get-DataProperty $failure 'Message') if ($Online) { return New-Finding -Category Apps -Check $Check -Severity WARN -Value $value ` -Hint (Get-Text 'Hint.Autodiscover.ExchangeOnlineElsewhere') } return New-Finding -Category Apps -Check $Check -Severity FAIL -Value $value ` -Hint (Get-Text 'Hint.Autodiscover.Unreachable') } if (-not (Test-AutodiscoverExchangeAnswer $Probe)) { $shown = "$(Get-DataProperty $Probe 'StatusCode')" $challenge = Get-AutodiscoverChallengeText $Probe if ($challenge) { $shown += ' ' + $challenge } $value = (Get-Text 'Value.Autodiscover.NotExchange') -f $Label, $shown $hint = Get-Text 'Hint.Autodiscover.NotExchange' if ($Online) { $hint = Get-Text 'Hint.Autodiscover.ExchangeOnlineOldServer' } return New-Finding -Category Apps -Check $Check -Severity FAIL -Value $value -Hint $hint } # Exchange answered on the Customer's own name: now its certificate is the question. $certificate = Get-DataProperty $Probe 'Certificate' if (-not $certificate) { return New-Finding -Category Apps -Check $Check -Severity INFO ` -Value (Get-Text 'Value.HttpsEndpoint.NoCertificate') -Hint (Get-Text 'Hint.Autodiscover.NotRecorded') } $policyErrors = "$(Get-DataProperty $certificate 'PolicyErrors')" if ($policyErrors -and $policyErrors -ne 'None') { $reasons = @() if ($policyErrors -match 'RemoteCertificateNameMismatch') { $reasons += Get-Text 'Value.HttpsEndpoint.NameMismatch' } if ($policyErrors -match 'RemoteCertificateChainErrors') { $reasons += (Get-Text 'Value.HttpsEndpoint.ChainErrors') -f ((Get-DataCollection $certificate 'ChainStatus') -join ', ') } if (-not $reasons.Count) { $reasons += $policyErrors } return New-Finding -Category Apps -Check $Check -Severity FAIL ` -Value ((Get-Text 'Value.Autodiscover.Certificate') -f $Label, ($reasons -join '; ')) ` -Hint (Get-Text 'Hint.Autodiscover.CertificateUntrusted') } $notAfter = Get-DataProperty $certificate 'NotAfter' $days = $null if ($notAfter -and $GatheredAt) { $days = ([datetime]$notAfter - [datetime]$GatheredAt).TotalDays $lifetime = Get-SeverityBelow $days $WarnDays $FailDays if ($lifetime -ne 'OK') { $value = (Get-Text 'Value.HttpsEndpoint.CertificateExpires') -f $days, [datetime]$notAfter if ($days -lt 0) { $value = (Get-Text 'Value.HttpsEndpoint.CertificateExpired') -f [datetime]$notAfter } return New-Finding -Category Apps -Check $Check -Severity $lifetime ` -Value ((Get-Text 'Value.Autodiscover.Certificate') -f $Label, $value) ` -Hint (Get-Text 'Hint.Autodiscover.CertificateExpiring') } } $connectMs = ConvertTo-Number (Get-DataProperty $Probe 'ConnectMs') if ($null -ne $connectMs -and (Get-Severity $connectMs $ResponseWarnMs ([double]::MaxValue)) -ne 'OK') { return New-Finding -Category Apps -Check $Check -Severity WARN ` -Value ((Get-Text 'Value.Autodiscover.Slow') -f $Label, $connectMs) ` -Hint (Get-Text 'Hint.Autodiscover.Slow') } $shownDays = '-' if ($null -ne $days) { $shownDays = '{0:N0}' -f $days } New-Finding -Category Apps -Check $Check -Severity OK ` -Value ((Get-Text 'Value.Autodiscover.Ok') -f $Label, (Get-AutodiscoverChallengeText $Probe), $shownDays, $connectMs) } function New-AutodiscoverRootFinding { <# .SYNOPSIS A Finding about https://<domain>/autodiscover/ when it answers in a way that matters, and nothing otherwise. Pure. .DESCRIPTION Outlook asks the root domain before autodiscover.<domain>. A web host answering there with anything but Exchange can make Outlook set the account up as IMAP (Microsoft KB 3049615): WARN. A root domain that only presents the wrong certificate is INFO: Outlook 2016 and later silence certificate errors at this step (Microsoft, Outlook 2016 Autodiscover order, step 6), but older Outlook and other mail clients show the warning KB 2783881 describes. A root that does not answer at all is how most domains look, and the Section shows it. #> [CmdletBinding()] [OutputType([psobject])] param([Parameter(Mandatory)]$Domain) $name = "$(Get-DataProperty $Domain 'Domain')" $probe = Get-DataProperty $Domain 'Root' if (-not $probe -or (Get-DataProperty $probe 'Error')) { return } $status = ConvertTo-Number (Get-DataProperty $probe 'StatusCode') if ($null -eq $status -or (Test-AutodiscoverExchangeAnswer $probe)) { return } $check = (Get-Text 'Check.Autodiscover.Root') -f $name $url = 'https://{0}/autodiscover/' -f $name if ($status -ge 200 -and $status -lt 300) { return New-Finding -Category Apps -Check $check -Severity WARN ` -Value ((Get-Text 'Value.Autodiscover.RootAnswers') -f $url, $status) ` -Hint (Get-Text 'Hint.Autodiscover.RootAnswers') } $certificate = Get-DataProperty $probe 'Certificate' $policyErrors = "$(Get-DataProperty $certificate 'PolicyErrors')" if ($certificate -and $policyErrors -and $policyErrors -ne 'None') { $reason = $policyErrors if ($policyErrors -match 'RemoteCertificateNameMismatch') { $reason = Get-Text 'Value.HttpsEndpoint.NameMismatch' } return New-Finding -Category Apps -Check $check -Severity INFO ` -Value ((Get-Text 'Value.Autodiscover.RootCertificate') -f $url, $status, $reason) ` -Hint (Get-Text 'Hint.Autodiscover.RootCertificate') } } function Get-AutodiscoverOverride { <# .SYNOPSIS The AutoDiscover values Outlook obeys, one per name: the policy's where both keys carry it, with the preference value it overrules. Pure. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()]$Data) $effective = [ordered]@{} foreach ($source in 'Policy', 'Preference') { foreach ($key in @((Get-DataCollection $Data 'Registry') | Where-Object { "$(Get-DataProperty $_ 'Source')" -eq $source })) { foreach ($v in (Get-DataCollection $key 'Values')) { $name = "$(Get-DataProperty $v 'Name')" if (-not $name) { continue } $id = $name.ToLowerInvariant() if ($effective.Contains($id)) { $effective[$id].Overrules = $true $effective[$id].OverruledValue = Get-DataProperty $v 'Value' continue } $effective[$id] = [pscustomobject]@{ Name = $name; Value = Get-DataProperty $v 'Value'; Source = $source; Overrules = $false; OverruledValue = $null } } } } foreach ($o in $effective.Values) { $o } } function Test-AutodiscoverOverrideOn { <# .SYNOPSIS Whether an override is switched on (a number other than 0). Pure. #> [CmdletBinding()] [OutputType([bool])] param([AllowNull()][AllowEmptyCollection()]$Override, [Parameter(Mandatory)][string]$Name) $found = @(@($Override) | Where-Object { $_ -and $_.Name -eq $Name }) if (-not $found.Count) { return $false } $number = ConvertTo-Number $found[0].Value $null -ne $number -and $number -ne 0 } function Get-AutodiscoverScpUrl { <# .SYNOPSIS The Autodiscover URLs the SCPs advertise, each with its date and whether it is the Customer's own server. Pure. .DESCRIPTION A pointer SCP carries an LDAP URL to another forest, which is not where Outlook connects, so only http(s) URLs are returned. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()]$Data) foreach ($entry in (Get-DataCollection (Get-DataProperty $Data 'Scp') 'Entries')) { foreach ($url in (Get-DataCollection $entry 'ServiceBindingInformation')) { if ("$url" -notmatch '^https?://') { continue } [pscustomobject]@{ Url = "$url" WhenChanged = Get-DataProperty $entry 'WhenChanged' OnPremises = -not (Test-AutodiscoverMicrosoftHost "$url") } } } } function Format-AutodiscoverDate { <# .SYNOPSIS A recorded date as yyyy-MM-dd, or '-' when there is none. Pure. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()]$Value) if ($null -eq $Value -or "$Value" -eq '') { return '-' } try { '{0:yyyy-MM-dd}' -f [datetime]$Value } catch { '-' } } function Get-AutodiscoverBlockedStep { <# .SYNOPSIS The overrides that stand in the way of the mailbox, by name. Pure. .DESCRIPTION PreferLocalXML always: a local file replaces whatever the server would answer. For a mailbox in Exchange Online, ExcludeExplicitO365Endpoint: it removes Outlook's direct Office 365 step, its safety net once the mailbox has moved (research check 2; Inference, Microsoft says no more than that the step is skipped). For an on-prem mailbox, the Exclude values of the steps that lead to its Exchange, when every such step is excluded - one step left open is enough for Outlook to find it. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()]$Data, [AllowNull()][AllowEmptyCollection()]$Override, [hashtable]$Parameters = @{}) $blocked = New-Object System.Collections.Generic.List[string] if (Test-AutodiscoverOverrideOn $Override 'PreferLocalXML') { $blocked.Add('PreferLocalXML') } $scpOnPremises = @(Get-AutodiscoverScpUrl -Data $Data | Where-Object { $_.OnPremises }).Count -gt 0 foreach ($domain in (Get-DataCollection $Data 'Domains')) { switch (ConvertTo-AutodiscoverMailboxLocation -Domain $domain -Parameters $Parameters) { 'ExchangeOnline' { if (Test-AutodiscoverOverrideOn $Override 'ExcludeExplicitO365Endpoint') { $blocked.Add('ExcludeExplicitO365Endpoint') } } 'OnPremises' { $redirect = Get-DataProperty $domain 'HttpRedirect' $status = ConvertTo-Number (Get-DataProperty $redirect 'StatusCode') $location = "$(Get-DataProperty $redirect 'Location')" $srv = @((Get-DataCollection (Get-DataProperty (Get-DataProperty $domain 'Dns') 'Srv') 'Records') | Where-Object { $_.Type -eq 'SRV' }) $works = @{ Scp = $scpOnPremises Root = Test-AutodiscoverExchangeAnswer (Get-DataProperty $domain 'Root') Autodiscover = Test-AutodiscoverExchangeAnswer (Get-DataProperty $domain 'Autodiscover') HttpRedirect = ($null -ne $status -and $status -ge 300 -and $status -lt 400 -and $location -match '^https://' -and -not (Test-AutodiscoverMicrosoftHost $location)) Srv = $srv.Count -gt 0 } $needed = @($script:AutodiscoverExcludeStep.Keys | Where-Object { $works[$script:AutodiscoverExcludeStep[$_]] }) $open = @($needed | Where-Object { -not (Test-AutodiscoverOverrideOn $Override $_) }) if ($needed.Count -and -not $open.Count) { foreach ($n in $needed) { $blocked.Add($n) } } } } } $blocked | Select-Object -Unique } function Format-AutodiscoverOverride { <# .SYNOPSIS One override as the Value shows it: name, value and which key it came from. Pure. #> [CmdletBinding()] [OutputType([string])] param([Parameter(Mandatory)]$Override) if ($Override.Overrules) { return (Get-Text 'Value.Autodiscover.OverrideOverrules') -f $Override.Name, $Override.Value, $Override.OverruledValue } $source = Get-Text 'Value.Autodiscover.Preference' if ($Override.Source -eq 'Policy') { $source = Get-Text 'Value.Autodiscover.Policy' } (Get-Text 'Value.Autodiscover.Override') -f $Override.Name, $Override.Value, $source } function New-AutodiscoverOverrideFinding { <# .SYNOPSIS The one Finding about the AutoDiscover registry values. Pure. .DESCRIPTION None set is OK. Set, but none in the mailbox's way, is INFO with the list: a Technician should still see a forgotten workaround. One in the way is WARN, naming it (Get-AutodiscoverBlockedStep says which are). Microsoft: "If you configure the Autodiscover registry/policy values incorrectly, you may prevent Outlook from obtaining Autodiscover information" (archived KB, unexpected Autodiscover behavior). #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()]$Data, [hashtable]$Parameters = @{}) $check = Get-Text 'Check.Autodiscover.Overrides' $keys = Get-DataCollection $Data 'Registry' if (-not $keys.Count) { return New-UnavailableFinding -Category Apps -Check $check -Hint (Get-Text 'Hint.Autodiscover.OverridesNotRead') } $failed = @($keys | Where-Object { Get-DataProperty $_ 'Error' }) if ($failed.Count) { return New-Finding -Category Apps -Check $check -Severity INFO ` -Value ((Get-Text 'Value.Autodiscover.RegistryFailed') -f (Get-DataProperty $failed[0] 'Path'), (Get-DataProperty $failed[0] 'Error')) ` -Hint (Get-Text 'Hint.Autodiscover.RegistryFailed') } $overrides = @(Get-AutodiscoverOverride -Data $Data) if (-not $overrides.Count) { return New-Finding -Category Apps -Check $check -Severity OK -Value (Get-Text 'Value.Autodiscover.NoOverrides') } $listed = @(foreach ($o in $overrides) { Format-AutodiscoverOverride $o }) -join ', ' $blocked = @(Get-AutodiscoverBlockedStep -Data $Data -Override $overrides -Parameters $Parameters) if ($blocked.Count) { $named = @(foreach ($b in $blocked) { $o = @($overrides | Where-Object { $_.Name -eq $b })[0] $source = Get-Text 'Value.Autodiscover.Preference' if ($o.Source -eq 'Policy') { $source = Get-Text 'Value.Autodiscover.Policy' } '{0} ({1})' -f $o.Name, $source }) -join ', ' return New-Finding -Category Apps -Check $check -Severity WARN ` -Value ((Get-Text 'Value.Autodiscover.OverridesBlocking') -f $listed, $named) ` -Hint ((Get-Text 'Hint.Autodiscover.OverrideRemove') -f $named) } New-Finding -Category Apps -Check $check -Severity INFO -Value $listed -Hint (Get-Text 'Hint.Autodiscover.OverridesHarmless') } function New-AutodiscoverScpFinding { <# .SYNOPSIS The one Finding comparing the Autodiscover SCP with where the mailbox is. Pure. .DESCRIPTION An SCP still naming on-prem Exchange while a mailbox is in Exchange Online makes Outlook ask that server first (KB 3012603; in a hybrid, the delay of KB 3137323): WARN, unless ExcludeScpLookup tells Outlook to skip it. No Finding on a machine known not to be domain-joined: there is no SCP to ask. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()]$Data, [hashtable]$Parameters = @{}) if ((Get-DataProperty $Data 'IsDomainJoined') -eq $false) { return } $check = Get-Text 'Check.Autodiscover.Scp' $scp = Get-DataProperty $Data 'Scp' if (-not $scp) { return New-UnavailableFinding -Category Apps -Check $check -Hint (Get-Text 'Hint.Autodiscover.ScpNotRead') } $failure = Get-DataProperty $scp 'Error' if ($failure) { return New-Finding -Category Apps -Check $check -Severity INFO ` -Value ((Get-Text 'Value.Autodiscover.ScpFailed') -f $failure) -Hint (Get-Text 'Hint.Autodiscover.ScpFailed') } $urls = @(Get-AutodiscoverScpUrl -Data $Data) $onPrem = @($urls | Where-Object { $_.OnPremises }) if (-not $onPrem.Count) { if ($urls.Count) { return New-Finding -Category Apps -Check $check -Severity OK ` -Value ((Get-Text 'Value.Autodiscover.ScpMicrosoft') -f (@($urls | ForEach-Object { $_.Url }) -join ', ')) } return New-Finding -Category Apps -Check $check -Severity OK -Value (Get-Text 'Value.Autodiscover.ScpNone') } $shown = @($onPrem | ForEach-Object { (Get-Text 'Value.Autodiscover.ScpUrl') -f $_.Url, (Format-AutodiscoverDate $_.WhenChanged) }) -join ', ' $online = New-Object System.Collections.Generic.List[string] $onPremises = $false foreach ($domain in (Get-DataCollection $Data 'Domains')) { switch (ConvertTo-AutodiscoverMailboxLocation -Domain $domain -Parameters $Parameters) { 'ExchangeOnline' { $online.Add("$(Get-DataProperty $domain 'Domain')") } 'OnPremises' { $onPremises = $true } } } if ($online.Count) { $value = (Get-Text 'Value.Autodiscover.ScpStale') -f $shown, ($online -join ', ') if (Test-AutodiscoverOverrideOn @(Get-AutodiscoverOverride -Data $Data) 'ExcludeScpLookup') { return New-Finding -Category Apps -Check $check -Severity INFO ` -Value ($value + (Get-Text 'Value.Autodiscover.ScpSkipped')) -Hint (Get-Text 'Hint.Autodiscover.ScpSkipped') } return New-Finding -Category Apps -Check $check -Severity WARN -Value $value -Hint (Get-Text 'Hint.Autodiscover.ScpStale') } if ($onPremises) { return New-Finding -Category Apps -Check $check -Severity OK -Value ((Get-Text 'Value.Autodiscover.ScpOnPremises') -f $shown) } New-Finding -Category Apps -Check $check -Severity INFO ` -Value ((Get-Text 'Value.Autodiscover.ScpUnknownMailbox') -f $shown) -Hint (Get-Text 'Hint.Autodiscover.ScpUnknownMailbox') } function ConvertTo-AutodiscoverMachineSection { <# .SYNOPSIS The Sections of what bends the lookup: registry values, SCPs, cached answers. Pure. .DESCRIPTION A cached answer's age is shown, not judged: Microsoft gives no age at which one is stale. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()]$Data) $inEffect = @(Get-AutodiscoverOverride -Data $Data) New-Section -Title (Get-Text 'Title.Autodiscover.Overrides') -Row @( foreach ($key in (Get-DataCollection $Data 'Registry')) { $source = "$(Get-DataProperty $key 'Source')" foreach ($v in (Get-DataCollection $key 'Values')) { $name = "$(Get-DataProperty $v 'Name')" [pscustomobject]@{ Source = $source Name = $name Value = Get-DataProperty $v 'Value' Type = Get-DataProperty $v 'Type' InEffect = [bool]@($inEffect | Where-Object { $_.Name -eq $name -and $_.Source -eq $source }).Count Key = Get-DataProperty $key 'Path' Error = $null } } if (Get-DataProperty $key 'Error') { [pscustomobject]@{ Source = $source; Name = $null; Value = $null; Type = $null; InEffect = $null Key = Get-DataProperty $key 'Path'; Error = Get-DataProperty $key 'Error' } } } ) $scp = Get-DataProperty $Data 'Scp' New-Section -Title (Get-Text 'Title.Autodiscover.Scp') -Row @( foreach ($entry in (Get-DataCollection $scp 'Entries')) { [pscustomobject]@{ Name = Get-DataProperty $entry 'Name' ServiceBindingInformation = (Get-DataCollection $entry 'ServiceBindingInformation') -join '; ' Keywords = (Get-DataCollection $entry 'Keywords') -join '; ' WhenChanged = Format-AutodiscoverDate (Get-DataProperty $entry 'WhenChanged') DistinguishedName = Get-DataProperty $entry 'DistinguishedName' } } ) $gatheredAt = Get-DataProperty $Data 'GatheredAt' New-Section -Title (Get-Text 'Title.Autodiscover.Cache') -Row @( foreach ($file in (Get-DataCollection $Data 'CacheFiles')) { $written = Get-DataProperty $file 'LastWriteTime' $age = $null try { if ($written -and $gatheredAt) { $age = [math]::Floor(([datetime]$gatheredAt - [datetime]$written).TotalDays) } } catch { } [pscustomobject]@{ Folder = Get-DataProperty $file 'Folder' Name = Get-DataProperty $file 'Name' LastWriteTime = Format-AutodiscoverDate $written AgeDays = $age Length = Get-DataProperty $file 'Length' } } ) } function ConvertTo-AutodiscoverSection { [CmdletBinding()] [OutputType([psobject])] param([AllowNull()]$Data) $domains = Get-DataCollection $Data 'Domains' if (-not $domains.Count) { $candidates = Get-DataCollection $Data 'Candidates' $errors = Get-DataCollection $Data 'SourceErrors' if ($candidates.Count -or $errors.Count) { New-Section -Title (Get-Text 'Title.Autodiscover.Domains') -Row @( foreach ($c in $candidates) { [pscustomobject]@{ Source = $c.Source; Address = $c.Address; Error = $null } } foreach ($e in $errors) { [pscustomobject]@{ Source = $e.Source; Address = $null; Error = $e.Error } } ) } return ConvertTo-AutodiscoverMachineSection -Data $Data } $describe = { param($Answer) if (-not $Answer) { return $null } $records = Get-DataCollection $Answer 'Records' if ($records.Count) { return (@($records | ForEach-Object { '{0} {1}' -f $_.Type, $_.Data }) -join '; ') } $id = Get-DataProperty $Answer 'ErrorId' if ($id) { return $id } 'no answer' } New-Section -Title (Get-Text 'Title.Autodiscover.Domains') -Row @( foreach ($d in $domains) { $dns = Get-DataProperty $d 'Dns' [pscustomobject]@{ Domain = Get-DataProperty $d 'Domain' Sources = ((Get-DataCollection $d 'Sources') -join ', ') Address = $(if (Get-DataProperty $d 'AddressIsUser') { 'user' } else { 'probe@' }) Autodiscover = & $describe (Get-DataProperty $dns 'Autodiscover') Srv = & $describe (Get-DataProperty $dns 'Srv') Root = & $describe (Get-DataProperty $dns 'Root') } } foreach ($e in (Get-DataCollection $Data 'SourceErrors')) { [pscustomobject]@{ Domain = $null; Sources = $e.Source; Address = $null; Autodiscover = $e.Error; Srv = $null; Root = $null } } ) New-Section -Title (Get-Text 'Title.Autodiscover.Urls') -Row @( foreach ($d in $domains) { foreach ($purpose in 'Root', 'Autodiscover', 'HttpRedirect', 'ExchangeOnline') { $probe = Get-DataProperty $d $purpose if (-not $probe) { continue } $certificate = Get-DataProperty $probe 'Certificate' $failure = Get-DataProperty $probe 'Error' $notAfter = Get-DataProperty $certificate 'NotAfter' $proxy = Get-DataProperty $probe 'Proxy' $answer = Get-DataProperty $probe 'Location' if ($purpose -eq 'ExchangeOnline' -and (Get-DataProperty $d 'V2Url')) { $answer = 'Url ' + (Get-DataProperty $d 'V2Url') } [pscustomobject]@{ Domain = Get-DataProperty $d 'Domain' Purpose = $purpose Method = Get-DataProperty $probe 'Method' Url = Get-DataProperty $probe 'Url' Proxy = $(if ($proxy) { $proxy } else { 'direct' }) Status = $(if ($failure) { 'failed at {0}' -f (Get-DataProperty $probe 'Stage') } else { Get-DataProperty $probe 'StatusCode' }) Answer = $answer Challenge = Get-AutodiscoverChallengeText $probe Issuer = Get-DataProperty $certificate 'Issuer' NotAfter = $(if ($notAfter) { '{0:yyyy-MM-dd}' -f [datetime]$notAfter }) PolicyErrors = Get-DataProperty $certificate 'PolicyErrors' ConnectMs = Get-DataProperty $probe 'ConnectMs' Error = Get-DataProperty $failure 'Message' } } } ) ConvertTo-AutodiscoverMachineSection -Data $Data } |