AzStackHCIDNS/AzStackHci.DNS.Helpers.psm1
|
Import-LocalizedData -BindingVariable ldTxt -FileName AzStackHci.DNS.Strings.psd1 # This file contains a list of discreet tests that can be run against the environment # Each test named Test-* is exported and discovered to be run by the user-facing function. # The user uses Include and Exclude parameters to run specific tests. (this provides a consistent experience across validators) # If tests have dependencies on other tests, or they should be run in a specific order, the pattern describe above should be removed. function Test-ExternalDnsResolution { <# .SYNOPSIS Validates external hostname resolution from all cluster nodes. .DESCRIPTION Tests that each configured DNS server on every cluster node can resolve an external hostname (default: microsoft.com). Uses Resolve-DnsName -DnsOnly against each DNS server independently. Retries up to 3 times with 5-second delays. Enables the DNS Client operational event log on the final retry attempt for diagnostic capture. If a web proxy is enabled (via netsh winhttp show advproxy), the test is skipped because the proxy may handle DNS resolution. Asserts: All DNS servers on all nodes can resolve the external hostname. Severity: CRITICAL - failure indicates broken outbound DNS which blocks deployment. .PARAMETER PsSession PowerShell remoting sessions to each cluster node. .PARAMETER ExternalName The external hostname to resolve. Defaults to 'microsoft.com' if not specified. Exactly one name must be provided. #> [CmdletBinding()] param ( [System.Management.Automation.Runspaces.PSSession[]] $PsSession, [string[]] $ExternalName ) $SkipDnsWithProxy = $ldTxt.SkipDnsWithProxy # Test if proxy is enabled and return because proxy maybe doing dns resolution function IsProxyEnabled { $line1, $line2, $line3, $JsonLines = netsh winhttp show advproxy $proxy = $JsonLines | ConvertFrom-Json -ErrorAction SilentlyContinue [bool]$proxy.Proxy } if (IsProxyEnabled) { $testDnsServer = @{ Resource = $SkipDnsWithProxy Status = 'SUCCESS' TimeStamp = [datetime]::UtcNow Source = $ENV:COMPUTERNAME Detail = $SkipDnsWithProxy -f $ENV:COMPUTERNAME } } else { if ([string]::IsNullOrEmpty($PSBoundParameters['ExternalName'])) { Log-Info "No DNS target found, using microsoft.com as fall back" -ConsoleOut -Type Warning $ExternalName = @('microsoft.com') } if ($ExternalName.count -ne 1) { throw "Expected 1 System_Check_DNS_External_Hostname_Resolution, found $($ExternalName.count)" } $TestDNSResolutionParams = @{} if ($PSBoundParameters['PsSession']) { $TestDNSResolutionParams.Add('PsSession', $PsSession) } # Use the local $ExternalName so the microsoft.com fallback above is respected. # (PSBoundParameters is a binding-time snapshot and would miss the fallback assignment.) if ($ExternalName) { $TestDNSResolutionParams.Add('TargetName', $ExternalName) } $testDnsServer = TestDNSResolution @TestDNSResolutionParams } # Write result to verbose log $testDnsServer | Foreach-Object { Log-Info $_.Detail -Type $(if ( $_.Status -eq 'FAILURE' ){ "Critical" } else { "INFO" } ) } # Collect lightweight per-node results for aggregation $detailResults = @() $detailResults += $testDnsServer | Foreach-Object { New-LightweightResult ` -Name 'AzStackHci_DNS_Test_External_Hostname_Resolution' ` -Status $PsItem.Status ` -Severity 'Critical' ` -TargetResourceName "$($PsItem.Source)/$($PsItem.Resource)" ` -Source $PsItem.Source ` -Resource $PsItem.Resource ` -Detail $PsItem.Detail } return @(New-AggregatedTestResult ` -TestName 'Test-ExternalDnsResolution' ` -DisplayName 'External DNS Resolution' ` -Description 'Validates external hostname resolution from each cluster node. Uses Resolve-DnsName -DnsOnly against all configured DNS servers to verify that the target hostname (default: microsoft.com) can be resolved. Tests each DNS server independently with up to 3 retries and 5-second delays between attempts. Enables DNS Client operational log on final retry for diagnostics.' ` -DetailResults $detailResults ` -ValidatorName 'DNS' ` -ResourceType 'DNS' ` -Remediation $ldTxt.ExternalDnsRemediation) } function Test-ActiveDirectoryDomainName { <# .SYNOPSIS Validates Active Directory domain FQDN resolution from all cluster nodes. .DESCRIPTION Tests that each configured DNS server on every cluster node can resolve the Active Directory domain FQDN. Uses Resolve-DnsName -DnsOnly against each DNS server independently with up to 3 retries and 5-second delays between attempts. Enables the DNS Client operational event log on the final retry for diagnostics. A resolvable AD domain name is required for domain join and cluster lifecycle operations. Asserts: All DNS servers on all nodes return a valid response for the domain FQDN. Severity: CRITICAL - failure blocks domain join. .PARAMETER PsSession PowerShell remoting sessions to each cluster node. .PARAMETER DomainFQDN The Active Directory domain FQDN to resolve (e.g., 'contoso.local'). #> [CmdletBinding()] param ( [System.Management.Automation.Runspaces.PSSession[]] $PsSession, [string[]] $DomainFQDN ) $TestDNSResolutionParams = @{} if ($PSBoundParameters['PsSession']) { $TestDNSResolutionParams.Add('PsSession', $PsSession) } if ($PSBoundParameters['DomainFQDN']) { $TestDNSResolutionParams.Add('TargetName', $DomainFQDN) } $testDnsServer = TestDNSResolution @TestDNSResolutionParams # Write result to verbose log $testDnsServer | Foreach-Object { Log-Info $_.Detail -Type $(if ( $_.Status -eq 'FAILURE' ){ "Critical" } else { "INFO" } ) } # Collect lightweight per-node results for aggregation $detailResults = @() $detailResults += $testDnsServer | Foreach-Object { New-LightweightResult ` -Name 'AzStackHci_DNS_Test_ActiveDirectory_DomainName_Resolution' ` -Status $PsItem.Status ` -Severity 'Critical' ` -TargetResourceName "$($PsItem.Source)/$($PsItem.Resource)" ` -Source $PsItem.Source ` -Resource $PsItem.Resource ` -Detail $PsItem.Detail } return @(New-AggregatedTestResult ` -TestName 'Test-ActiveDirectoryDomainName' ` -DisplayName 'Active Directory Domain Name Resolution' ` -Description 'Validates that the Active Directory domain FQDN can be resolved from each cluster node. Queries all configured DNS servers using Resolve-DnsName -DnsOnly with up to 3 retries per server. A resolvable AD domain name is required for domain join and cluster operations.' ` -DetailResults $detailResults ` -ValidatorName 'DNS' ` -ResourceType 'DNS' ` -Remediation $ldTxt.DomainNameDnsRemediation) } function Test-IPMapForClusterNodes { <# .SYNOPSIS Validates that DNS A records for cluster nodes resolve to their expected IP addresses. .DESCRIPTION For each node in the physicalNodesSettings, constructs the FQDN (nodeName.domainFQDN) and queries all configured DNS servers using Resolve-DnsName -DnsOnly with up to 3 retries and 5-second delays. Compares the returned IP addresses against the expected management IP from the deployment answer file. Asserts: Each node FQDN resolves to its expected IP address on every DNS server. Severity: INFORMATIONAL - mismatches are reported but do not block deployment because DNS records may be created dynamically during domain join. .PARAMETER PhysicalMachineIpMap Hashtable mapping node hostnames to their expected management IP addresses. .PARAMETER DomainFQDN The Active Directory domain FQDN used to construct node FQDNs. .PARAMETER PsSession PowerShell remoting sessions to each cluster node. #> [CmdletBinding()] param ( [hashtable] $PhysicalMachineIpMap, [string] $DomainFQDN, [System.Management.Automation.Runspaces.PSSession[]] $PsSession ) try { $TestDNSResolutionParams = @{} if ($PSBoundParameters['PsSession']) { $TestDNSResolutionParams.Add('PsSession', $PsSession) } $IpMap = @{} foreach ($key in $PhysicalMachineIpMap.Keys) { $fqdnKey = "$key.$DomainFQDN" $IpMap.Add($fqdnKey, $PhysicalMachineIpMap[$key]) } $TestDNSResolutionParams.Add('IpMap', $IpMap) $result = TestDNSResolution @TestDNSResolutionParams $detailResults = @() foreach ($res in $result) { Log-Info $res.Detail -Type $(if ( $res.Status -eq 'FAILURE' ){ "Critical" } else { "INFO" } ) $detailResults += New-LightweightResult ` -Name 'AzStackHci_DNS_Test_IPMap_For_Cluster_Nodes' ` -Status $res.Status ` -Severity 'INFORMATIONAL' ` -TargetResourceName "$($res.Source)/$($res.Resource)" ` -Source $res.Source ` -Resource $res.Resource ` -Detail $res.Detail } return @(New-AggregatedTestResult ` -TestName 'Test-IPMapForClusterNodes' ` -DisplayName 'IP Map For Cluster Nodes' ` -Description 'Validates that DNS A records for each node in physicalNodesSettings resolve to the expected IP addresses. For each node, constructs the FQDN (nodeName.domainFQDN) and queries all configured DNS servers using Resolve-DnsName -DnsOnly. Compares returned IP addresses against the expected IP from the deployment answer file. Retries up to 3 times with 5-second delays.' ` -DetailResults $detailResults ` -ValidatorName 'DNS' ` -ResourceType 'DNS' ` -Remediation $ldTxt.IPMapRemediation) } catch { Log-Info "Test-IPMapForClusterNodes failed with error: $_" -Type WARNING } } function Test-IPMapForCNO { <# .SYNOPSIS Validates that the required DNS A record for the cluster name object (CNO) is pre-registered and resolves to the reserved cluster IP, before the cluster is created. .DESCRIPTION This check only runs for local identity (ADLess) deployments that use an external DNS server. In that configuration the operator must pre-register the cluster name (CNO) DNS A record before deployment, because the cluster cannot register its own record against an external DNS server it does not own. (For AD-integrated DNS the cluster registers its own record at bring-online, and for local identity with internal DNS the DNS server does not yet exist during validation - in both of those cases the check is excluded upstream and never runs.) Resolves the cluster FQDN (ClusterName.DomainFQDN) on every configured DNS server and compares the returned A record against the reserved cluster IP (ClusterIP). The reserved IP is the first IP address of the infrastructure network range allocated for the system - the same address the platform reserves for the CNO during deployment. Nothing is derived from a heuristic. Because pre-registration is REQUIRED in this scenario, three outcomes are reported (all INFORMATIONAL severity, non-blocking per SDP Phase 1): - No record found -> FAILURE. The required pre-registered record is missing; left uncreated it makes deploy-time cluster DNS verification fail after the cluster is created. - Record matches -> SUCCESS. The pre-registered record points at the reserved cluster IP. - Record mismatch -> FAILURE. A stale or incorrect record points at the wrong IP; left in place it makes deploy-time cluster DNS verification fail with a hard-to-diagnose timeout. If the cluster name, DNS zone, or reserved cluster IP cannot be determined the test reports a FAILURE (rather than skipping), because in this scenario the record is required and cannot be validated without those inputs. .PARAMETER ClusterName The cluster name object (CNO) short name. The FQDN is built as ClusterName.DomainFQDN. .PARAMETER DomainFQDN The DNS zone name (local identity) used to build the cluster FQDN. .PARAMETER ClusterIP The IP address that Azure Local reserves for the cluster name (CNO) - the first IP address of the infrastructure network range allocated for the system. .PARAMETER PsSession PowerShell remoting sessions to each cluster node. #> [CmdletBinding()] param ( [string] $ClusterName, [string] $DomainFQDN, [string] $ClusterIP, [System.Management.Automation.Runspaces.PSSession[]] $PsSession ) try { $detailResults = @() $ipMapForCnoDescription = 'Validates that the required DNS A record for the cluster name object (CNO) is pre-registered and resolves to the reserved cluster IP before the cluster is created. This pre-registration is required for local identity deployments that use an external DNS server; a missing record or a record pointing at the wrong IP is reported so it can be corrected before it fails deploy-time cluster DNS verification.' # In the only scenario where this test runs (local identity + external DNS) the cluster name, # DNS zone, and reserved cluster IP are all required to validate the mandatory pre-registration. # If any is missing we cannot validate the required record, so report a FAILURE rather than skip. if ([string]::IsNullOrWhiteSpace($ClusterName) -or [string]::IsNullOrWhiteSpace($DomainFQDN) -or [string]::IsNullOrWhiteSpace($ClusterIP)) { $inputDetail = $ldTxt.IPMapForCNOInputMissing Log-Info $inputDetail -Type Critical $detailResults += New-LightweightResult ` -Name 'AzStackHci_DNS_Test_IPMap_For_CNO' ` -Status 'FAILURE' ` -Severity 'INFORMATIONAL' ` -TargetResourceName "CNO/$ClusterName" ` -Source 'CNO' ` -Resource $ClusterName ` -Detail $inputDetail return @(New-AggregatedTestResult ` -TestName 'Test-IPMapForCNO' ` -DisplayName 'IP Map For CNO' ` -Description $ipMapForCnoDescription ` -DetailResults $detailResults ` -ValidatorName 'DNS' ` -ResourceType 'DNS' ` -Remediation $ldTxt.IPMapForCNORemediation) } $clusterFqdn = "$ClusterName.$DomainFQDN" $IpMap = @{ $clusterFqdn = $ClusterIP } $TestDNSResolutionParams = @{ IpMap = $IpMap } if ($PSBoundParameters['PsSession']) { $TestDNSResolutionParams.Add('PsSession', $PsSession) } $result = TestDNSResolution @TestDNSResolutionParams foreach ($res in $result) { # Local identity + external DNS REQUIRES the CNO record to be pre-registered and correct: # FAILURE + a record was returned -> stale/incorrect record (wrong IP) -> FAILURE # FAILURE + no record was returned -> required record missing -> FAILURE # SUCCESS -> record present and correct -> SUCCESS $status = $res.Status $detail = $res.Detail if ($res.Status -eq 'FAILURE') { if (-not [string]::IsNullOrWhiteSpace($res.ResolvedIps)) { $status = 'FAILURE' $detail = $ldTxt.IPMapForCNOMismatch -f $clusterFqdn, $res.Resource, $res.ResolvedIps, $ClusterIP } else { $status = 'FAILURE' $detail = $ldTxt.IPMapForCNOMissing -f $clusterFqdn, $res.Resource, $ClusterIP } } else { $detail = $ldTxt.IPMapForCNOMatch -f $clusterFqdn, $res.Resource, $ClusterIP } Log-Info $detail -Type $(if ( $status -eq 'FAILURE' ){ "Critical" } else { "INFO" } ) $detailResults += New-LightweightResult ` -Name 'AzStackHci_DNS_Test_IPMap_For_CNO' ` -Status $status ` -Severity 'INFORMATIONAL' ` -TargetResourceName "$($res.Source)/$($res.Resource)" ` -Source $res.Source ` -Resource $res.Resource ` -Detail $detail } return @(New-AggregatedTestResult ` -TestName 'Test-IPMapForCNO' ` -DisplayName 'IP Map For CNO' ` -Description $ipMapForCnoDescription ` -DetailResults $detailResults ` -ValidatorName 'DNS' ` -ResourceType 'DNS' ` -Remediation $ldTxt.IPMapForCNORemediation) } catch { # A thrown error must not look like "passed". Emit an explicit FAILURE aggregated result # (INFORMATIONAL severity, SDP Phase 1) so the missing validation is visible and actionable. $errorDetail = $ldTxt.IPMapForCNOUnexpectedError -f $_.Exception.Message Log-Info $errorDetail -Type Critical $errorResult = New-LightweightResult ` -Name 'AzStackHci_DNS_Test_IPMap_For_CNO' ` -Status 'FAILURE' ` -Severity 'INFORMATIONAL' ` -TargetResourceName "CNO/$ClusterName" ` -Source 'CNO' ` -Resource $ClusterName ` -Detail $errorDetail return @(New-AggregatedTestResult ` -TestName 'Test-IPMapForCNO' ` -DisplayName 'IP Map For CNO' ` -Description 'Validates that the required DNS A record for the cluster name object (CNO) is pre-registered and resolves to the reserved cluster IP before the cluster is created.' ` -DetailResults @($errorResult) ` -ValidatorName 'DNS' ` -ResourceType 'DNS' ` -Remediation $ldTxt.IPMapForCNORemediation) } } function Test-LocalClusterDNSResolution { <# .SYNOPSIS Validates that all cluster node names and the cluster name are resolvable in DNS. .DESCRIPTION Constructs FQDNs for each node (nodeName.domainFqdn) and the cluster (clusterName.domainFqdn), then queries all configured DNS servers using Resolve-DnsName -DnsOnly with up to 3 retries and 5-second delays. All names must be resolvable for cluster formation and lifecycle operations. If resolution fails and the environment uses Active Directory (not LocalIdentity), a secondary test checks for AD-integrated DNS zones via LDAP (TestAdIntegratedDns). If AD-integrated DNS is present, the local resolution failure is downgraded to INFORMATIONAL because cluster names will be dynamically registered during domain join. Asserts: Every DNS server can resolve all node FQDNs and the cluster FQDN. Severity: CRITICAL (downgraded to INFORMATIONAL if AD-integrated DNS passes). .PARAMETER PsSession PowerShell remoting sessions to each cluster node. .PARAMETER PhysicalMachineNames Array of node hostnames (short names without domain suffix). .PARAMETER ClusterName The cluster name (short name without domain suffix). .PARAMETER DomainFqdn The Active Directory domain FQDN for constructing fully-qualified names. .PARAMETER DomainCredential Credential for AD-integrated DNS zone lookup (used in fallback test). .PARAMETER IsLocalIdentityEnvironment Switch indicating an AD-less (LocalIdentity) deployment. When set, the AD-integrated DNS fallback test is skipped and failures remain CRITICAL. #> [CmdletBinding()] param ( [System.Management.Automation.Runspaces.PSSession[]] $PsSession, [string[]] $PhysicalMachineNames, [string] $ClusterName, # AD integration parameters [Parameter()] [System.String] $DomainFqdn, [Parameter()] [System.Management.Automation.PSCredential] $DomainCredential, [Parameter()] [switch] $IsLocalIdentityEnvironment ) $TestDNSResolutionParams = @{} $severity = 'Critical' if ($PSBoundParameters['PsSession']) { $TestDNSResolutionParams.Add('PsSession', $PsSession) } # Add fully qualified cluster and node names to resolve. # Cluster name is already fully qualified, so just add it to the list. $targetNames = @() $targetNames += $PhysicalMachineNames | ForEach-Object { "$_.$DomainFqdn" } $targetNames += "$ClusterName.$DomainFqdn" $TestDNSResolutionParams.Add('TargetName', $targetNames) $testDnsServer = TestDNSResolution @TestDNSResolutionParams # Write result to verbose log $testDnsServer | Foreach-Object { Log-Info $_.Detail -Type $(if ( $_.Status -eq 'FAILURE' ){ $severity } else { "INFO" } ) } # Collect lightweight per-node results for aggregation $detailResults = @() $detailResults += $testDnsServer | Foreach-Object { New-LightweightResult ` -Name 'AzStackHci_DNS_Test_Local_Cluster_DNS_Resolution' ` -Status $PsItem.Status ` -Severity $severity ` -TargetResourceName "$($PsItem.Source)/$($PsItem.Resource)" ` -Source $PsItem.Source ` -Resource $PsItem.Resource ` -Detail $PsItem.Detail } $LocalClusterDNSResult = @(New-AggregatedTestResult ` -TestName 'Test-LocalClusterDNSResolution' ` -DisplayName 'Local Cluster DNS Resolution' ` -Description 'Validates that each DNS server can resolve all cluster node names and the cluster name. Constructs FQDNs for each node (nodeName.domainFqdn) and the cluster (clusterName.domainFqdn), then queries all configured DNS servers using Resolve-DnsName -DnsOnly with up to 3 retries and 5-second delays. All names must be resolvable for cluster formation and lifecycle operations.' ` -DetailResults $detailResults ` -ValidatorName 'DNS' ` -ResourceType 'DNS' ` -Remediation $ldTxt.LocalClusterDnsRemediation) # Check if the test failed and if so, check if AD integration test parameters are set # If AD integration test parameters are set, run the AD integrated DNS test # If the AD integrated DNS test fails, return the result of the AD integrated DNS test (critical) and the local cluster DNS test (critical) # If the AD integrated DNS test passes, return the result of the non AD integrated DNS test if ('FAILURE' -in $LocalClusterDNSResult.Status) { Log-Info -Message "Local Cluster member DNS resolution test failed. Checking if AD integration test parameters are set." -Type $severity # AD deployment's have DomainCredential and DomainFqdn set, AD-less deployments do not have these parameters set. if (-not $PSBoundParameters['IsLocalIdentityEnvironment']) { Log-Info -Message "AD integration test parameters are set. Running AD integrated DNS test." -Type $severity $adIntDnsResult = TestAdIntegratedDns -DomainFqdn $DomainFqdn -DomainCredential $DomainCredential # both tests have failed return both results if ($adIntDnsResult.Status -eq 'FAILURE') { Log-Info -Message "AD integrated DNS test failed. Returning both local resolution and AD integrated together" -Type $severity return ($LocalClusterDNSResult + $adIntDnsResult) } else { # This is happy path AD deployment has AD integrated DNS. Log-Info -Message "AD integrated DNS test passed. We don't need to block the life cycle operation. Returning AD Integrated DNS test result and downgrading A record test to informational." $LocalClusterDNSResult | ForEach-Object { $_.Severity = 'INFORMATIONAL' if ($_.AdditionalData -and $_.AdditionalData.ContainsKey('Detail')) { $_.AdditionalData['Detail'] += "`n[Severity downgraded from CRITICAL to INFORMATIONAL: AD Integrated DNS passed, cluster names will be dynamically registered]" } } return ($LocalClusterDNSResult + $adIntDnsResult) } } else { Log-Info -Message "AD integration test parameters are not set. Local Cluster member DNS resolution test failed. This will block lifecycle operation." -Type $severity return $LocalClusterDNSResult } } else { Log-info -Message "Local Cluster member DNS resolution test passed. Returning." return $LocalClusterDNSResult } } function TestDNSResolution { <# .SYNOPSIS Test DNS Resolution of a target name by testing each dns server configured on any remote machine. .DESCRIPTION This function will test the DNS resolution of a target name by testing each DNS server configured on the machine. It will return the status of each DNS server and the result of the DNS query. It will also enable the DNS client operational log to get the logs for last attempt during failures. It will then disable the log if it was not enabled in the first place. It's intended to be a reusable function to facilitate the testing of DNS resolution in a consistent manner across types of names internal/external/node/cluster etc. When -DnsServer is supplied, the per-target query is sent only to those server IPs and the local Get-DnsClientServerAddress enumeration is skipped. This lets callers target specific authoritative name servers (for example, the cluster DNS posture probe) while still reusing the retry, -QuickTimeout, and DNS Client Operational Log capture behaviour of this helper. .PARAMETER TargetName One or more DNS names to resolve. May also be supplied via $IpMap keys. .PARAMETER IpMap Optional hashtable of TargetName -> expected IPv4. When supplied, a returned record is only SUCCESS when the observed IP set contains the expected IP for that target. .PARAMETER PsSession Optional remoting sessions to fan the resolution out across. When omitted, runs locally. .PARAMETER DnsServer Optional explicit list of DNS server IPs to query. When supplied with one or more entries, the helper queries only these servers and skips the local-resolver enumeration. When $null or empty, behaviour is unchanged: the helper enumerates DNS servers via Get-NetAdapter + Get-DnsClientServerAddress on the node executing the probe. #> [CmdletBinding()] param ( [string[]] $TargetName, [hashtable] $IpMap, [System.Management.Automation.Runspaces.PSSession[]] $PsSession, [string[]] $DnsServer ) try { # scriptblock to test dns resolution for each dns server $testDnsSb = { $TargetName = $args[0] -split ' ' $IpMap = $args[1] # Pass in localized strings $NoDnsConfigured = $args[2] $QueryDnsFail = $args[3] $QueryDnsPass = $args[4] $ExpectedDnsResultPass = $args[5] $ExpectedDnsResultFail = $args[6] # Optional caller-supplied DNS server override. When non-empty we skip the local # Get-DnsClientServerAddress enumeration and use these servers instead. This is the # reuse seam for callers that need to target specific authoritative name servers # (for example Test-ClusterDnsPosture) without re-implementing the retry / QuickTimeout / # DNS Client Operational Log capture wrapper around Resolve-DnsName. $SuppliedDnsServers = $args[7] $AdditionalData = @() # DNS servers: prefer caller-supplied list, else enumerate local resolvers $dnsServers = @() if ($SuppliedDnsServers -and @($SuppliedDnsServers).Count -gt 0) { $dnsServers = @($SuppliedDnsServers | Where-Object { $_ } | Sort-Object -Unique) } else { $netAdapter = Get-NetAdapter | Where-Object Status -EQ Up $dnsServer = Get-DnsClientServerAddress -InterfaceIndex $netAdapter.ifIndex -AddressFamily IPv4 $dnsServers += $dnsServer | ForEach-Object { $PSITEM.Address } | Sort-Object | Get-Unique } # set target name to ipmap key if applicable $UseMap = ![string]::IsNullOrEmpty($IpMap) if ($UseMap) { $TargetName = $IpMap.Keys } if (-not $dnsServers) { return @{ Resource = $NoDnsConfigured Status = 'FAILURE' TimeStamp = [datetime]::UtcNow Source = $ENV:COMPUTERNAME Detail = $NoDnsConfigured } } else { foreach ($target in $TargetName) { foreach ($dnsServer in $dnsServers) { $status = 'FAILURE' $attempt = 0 $maxRetry = 3 $sleepInSeconds = 5 $dnsLogNeedsToBeDisabled = $false $dnsLogAlreadyEnabled = $false $preDnsTimeStamp = $null while ($attempt -lt $maxRetry -and $status -eq 'FAILURE') { try { $attempt++ $dnsFailure = $null $dnsResult = $null # if this is the last attempt, check if the DNS client operational log is enabled, enabled it and reads the log for debug information if ($attempt -eq ($maxRetry - 1)) { $wevtutilglcmd = "& wevtutil gl Microsoft-Windows-DNS-Client/Operational" $dnsLogState = Invoke-Expression -command $wevtutilglcmd | select-string -pattern "enabled: (.*)" if ($dnsLogState -like '*false') { # enable the dns log for 5 minutes to get the logs and set the size to 10MB & wevtutil sl Microsoft-Windows-DNS-Client/Operational /e:true /ms:10485760 $dnsLogNeedsToBeDisabled = $true } else { $dnsLogAlreadyEnabled = $true } $preDnsTimeStamp = Get-Date -Format "yyyy-MM-ddTHH:mm:ss.fffZ" -AsUTC } $dnsResult = Resolve-DnsName -Name $target -Server $dnsServer -DnsOnly -ErrorAction SilentlyContinue -QuickTimeout -Type A } catch { $dnsFailure = $_.Exception.Message } if ([int]($dnsResult.count) -eq 0) { $detail = $QueryDnsFail -f $dnsServer, $target, $ENV:COMPUTERNAME, "$attempt/$maxRetry", [int]($dnsResult.count), $dnsFailure } else { $detail = $QueryDnsPass -f $dnsServer, $target, $ENV:COMPUTERNAME, "$attempt/$maxRetry", [int]($dnsResult.count), ($dnsResult.IpAddress -join ',') } if ($dnsResult) { if ($dnsResult[0] -is [Microsoft.DnsClient.Commands.DnsRecord] -or $dnsResult[0].PSObject.Properties['IPAddress']) { # If there is no IpMap provided, consider any valid response as success if (!$UseMap) { $status = 'SUCCESS' break } else { # If there the IpMap is provided but we don't have an expected IP for this target, consider any valid response as success # When we do have an expected IP for this target, check if the returned IPs contain the expected IP if ( -not $IpMap.ContainsKey($target) ) { $status = 'SUCCESS' break } else { # Check if the returned IPs contain the expected IP if ($dnsResult.IpAddress -contains $IpMap[$target]) { $status = 'SUCCESS' $detail = "`r`n" + ($ExpectedDnsResultPass -f $target, $ENV:COMPUTERNAME, $IpMap[$target], ($dnsResult.IpAddress -join ','), $dnsServer, "$attempt/$maxRetry") break } else { $status = 'FAILURE' $detail = "`r`n" + ($ExpectedDnsResultFail -f $target, $ENV:COMPUTERNAME, $IpMap[$target], ($dnsResult.IpAddress -join ','), $dnsServer, "$attempt/$maxRetry") } } } } else { $status = 'FAILURE' } } else { $status = 'FAILURE' } Start-Sleep -Second $sleepInSeconds } # If the dns log is enabled get the dns logs for this domain if ($dnsLogNeedsToBeDisabled -eq $true -or $dnsLogAlreadyEnabled) { $xPathFilter = "*[System[Provider[@Name='Microsoft-Windows-DNS-Client']]] and *[EventData[Data[@Name='QueryName']='$target']] and *[System[TimeCreated[@SystemTime > '$preDnsTimeStamp']]]" $dnsEvents = Get-WinEvent -LogName "Microsoft-Windows-DNS-Client/Operational" -FilterXPath $xPathFilter -ErrorAction SilentlyContinue | sort-object TimeCreated | Foreach-Object { "[{0}] [{1}] - {2}" -f $_.TimeCreated, $_.LevelDisplayName, $_.Message} if ($dnsEvents) { $detail += "`n`nDNS Client Operational Log:`n$($dnsEvents -join "`r`n" | Out-String)" } # Disable the DNS client operational log if it was enabled in the first place if ($dnsLogNeedsToBeDisabled -eq $true) { $wevtutilslCmd = "& wevtutil sl Microsoft-Windows-DNS-Client/Operational /e:false" Invoke-Expression -Command $wevtutilslCmd } } $resolvedIps = if ($status -eq 'SUCCESS' -and $dnsResult) { $dnsResult.IpAddress -join ',' } else { $null } # RecordIps captures the resolved A record(s) regardless of SUCCESS/FAILURE so callers can # distinguish "no record returned" from "record returned but IP mismatched" without parsing Detail. $recordIps = if ($dnsResult -and $dnsResult.IpAddress) { $dnsResult.IpAddress -join ',' } else { $null } $AdditionalData += @{ Resource = $dnsServer Status = $status TimeStamp = [datetime]::UtcNow Source = $ENV:COMPUTERNAME Detail = if ($resolvedIps) { "$detail`nIPs: $resolvedIps" } else { $detail } ResolvedIps = $recordIps } } } } $AdditionalData } # run scriptblock $dnsargs = @{ ArgumentList = $TargetName, $IpMap, $ldTxt.NoDnsConfigured, $ldTxt.QueryDnsFail, $ldTxt.QueryDnsPass, $ldTxt.ExpectedDnsResultPass, $ldTxt.ExpectedDnsResultFail, $DnsServer } $testDnsServer = if ($PsSession) { Invoke-Command -Session $PsSession -ScriptBlock $testDnsSb @dnsArgs } else { Invoke-Command -ScriptBlock $testDnsSb @dnsArgs } return $testDnsServer } catch { throw "Failed to run TestDNSResolution: $($_.Exception.Message)" } } function Get-LdapDomain { [CmdletBinding()] [OutputType([System.String])] Param ( [Parameter(Mandatory = $true)] [System.String] $Domain ) $ErrorActionPreference = [System.Management.Automation.ActionPreference]::Stop $ldapDomain = ($Domain.Split('.') | ForEach-Object {"DC=$_"}) -join ',' return $ldapDomain } function TestAdIntegratedDns { [CmdletBinding()] [OutputType([System.Boolean])] Param ( [Parameter(Mandatory = $true)] [System.String] $DomainFqdn, [Parameter(Mandatory = $true)] [System.Management.Automation.PSCredential] $DomainCredential ) try { $ErrorActionPreference = [System.Management.Automation.ActionPreference]::Stop $severity = 'Critical' Log-Info -Message 'Importing module ActiveDirectory.' Import-Module ActiveDirectory -Verbose:$false # Convert domain name to fully qualified LDAP name $ldapDomain = Get-LdapDomain -Domain $DomainFqdn Log-Info -Message "LdapDomain to search is '$ldapDomain'." # Get forest root domain so we can search for forest zones as well as domain zones $rootDomain = Get-LdapDomain -Domain (Get-ADForest -Credential $DomainCredential -Server $DomainFqdn).RootDomain Log-Info -Message "RootDomain to search is '$rootDomain'." # Build list of locations to search - including legacy Windows 2000 locations $searchBases = @( "DC=$DomainFqdn,CN=MicrosoftDNS,DC=DomainDnsZones,$ldapDomain" "DC=$DomainFqdn,CN=MicrosoftDNS,CN=System,$ldapDomain" "DC=$DomainFqdn,CN=MicrosoftDNS,DC=ForestDnsZones,$rootDomain" "DC=$DomainFqdn,CN=MicrosoftDNS,CN=System,$rootDomain" ) $result = $false # Search each location until one is found foreach ($searchBase in $searchBases) { if (-not $result) { Log-Info -Message "Searching for DNS zone container '$searchBase'." try { $dnsZone = Get-ADObject -SearchBase $searchBase -LDAPFilter '(objectClass=dnsZone)' -Properties @('dnsProperty') -Credential $DomainCredential -Server $DomainFqdn Log-Info -Message "Found DNS zone container '$searchBase'." $result = $true } catch { Log-Info -Message "DNS zone container '$searchBase' does not exist." } } } # Write to log file and set status if ($result) { $dtl = $ldTxt.AdIntDnsPass -f $rootDomain, $dnsZone.DistinguishedName Log-Info -Message $dtl $Status = 'SUCCESS' } else { $dtl = $ldTxt.AdIntDnsFail -f $rootDomain Log-Info -Message $dtl -Type $severity $Status = 'FAILURE' } # Write result $now = [datetime]::UtcNow $params = @{ Name = 'AzStackHci_DNS_Test_ActiveDirectory_Integrated_DNS' Title = 'Test Active Directory Integrated DNS' DisplayName = 'Test Active Directory Integrated DNS' Severity = $severity Description = 'Test Active Directory Integrated DNS' Tags = @{ Service = 'System' } Remediation = $ldTxt.AdIntDnsRemediation TargetResourceID = "$ENV:COMPUTERNAME/$rootDomain" TargetResourceName = $rootDomain TargetResourceType = 'DNS' Timestamp = $now Status = $Status AdditionalData = @{ Resource = $rootDomain Source = $ENV:COMPUTERNAME Status = $Status Detail = $dtl } HealthCheckSource = $ENV:EnvChkrId } return (New-AzStackHciResultObject @params) } catch { throw "Error checking Active Directory Integrated DNS: $_" } } function Get-ClusterDnsPostureVerdict { <# .SYNOPSIS Pure function that maps a probe-fact hashtable to one of the 13 verdict codes. .DESCRIPTION Implements the decision tree from the Test-ClusterDnsPosture design doc (Section 5). Kept as a separate function so the verdict logic is independently unit-testable without needing to mock Resolve-DnsName end-to-end. .PARAMETER Probe Hashtable carrying probe outcomes: SoaResolved (bool), AllNsUnreachable (bool), ExistingRecordPresent (bool), RdataMatchesClusterIp ('True'/'False'/'unknown'), AllNsReplicaParity (bool), ZoneIsADIntegrated (bool). .PARAMETER IsLocalIdentity True when this is an AD-less (LocalIdentity) deployment. #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [hashtable]$Probe, [Parameter(Mandatory = $false)] [bool]$IsLocalIdentity ) if (-not $Probe.SoaResolved) { return 'SKIP_NoZone' } if ($Probe.AllNsUnreachable) { return 'AMBER_NsUnreachable' } if ($IsLocalIdentity) { # ADLess decision branch. With no DACL read, we use the AD-integrated zone # heuristic as a proxy for Secure-only policy. AD-integrated zones are the # path where Secure-only blocks anonymous DDNS; file-backed zones typically # allow Both / unauthenticated UPDATE. if ($Probe.ZoneIsADIntegrated) { return 'RED_AdLess_SecureOnly' } if ($Probe.ExistingRecordPresent -and $Probe.RdataMatchesClusterIp -eq 'True') { return 'GREEN_AdLess_IdempotentMatch' } if ($Probe.ExistingRecordPresent) { return 'AMBER_AdLess_ForeignOwner' } return 'GREEN_AdLess_WriteWillSucceed' } else { # AD-Joined decision branch if ($Probe.ExistingRecordPresent -and $Probe.RdataMatchesClusterIp -eq 'True') { return 'GREEN_AdJoined_PreStaged' } if ($Probe.ExistingRecordPresent -and $Probe.RdataMatchesClusterIp -eq 'False') { return 'RED_AdJoined_WrongIp' } if (-not $Probe.AllNsReplicaParity) { return 'AMBER_AdJoined_ReplicationDrift' } if ($Probe.ZoneIsADIntegrated) { return 'GREEN_AdJoined_FreshWrite' } return 'AMBER_AdJoined_FileBackedZone' } } function Test-ClusterDnsPosture { <# .SYNOPSIS Read-only probe of authoritative DNS posture for the cluster CNO record. .DESCRIPTION Predicts whether the Failover Cluster CNO will be able to write its DNS A record at bring-online by querying the authoritative name servers for the deployment-target zone. Distinguishes the AD-Joined Kerberos identity path from the ADLess anonymous DDNS path. Three stages, all read-only: 1) Resolve SOA + NS list for the zone. 2) Per authoritative NS: resolve NS IP, then query the cluster FQDN A record directly against that NS. 3) Infer posture (existence, RDATA, replica parity, AD-integrated heuristic) and compute one of 13 verdict codes. The function NEVER writes to DNS. The synthetic-write tool (Send-DnsUpdate) stays lab-only. Maps verdict codes to per-row Status: RED_* -> FAILURE AMBER_* -> WARNING GREEN_* / SKIP_* -> SUCCESS Severity is INFORMATIONAL for SDP Phase 1; promotion to CRITICAL/WARNING happens in a follow-up after 30+ days of telemetry confirms low false-positive rate. .PARAMETER PsSession PowerShell remoting sessions to each cluster node. When omitted, runs locally. .PARAMETER ClusterName The cluster short name (the CNO label) without domain suffix. .PARAMETER DomainFQDN The DNS zone name. For AD-Joined deployments this is the AD domain FQDN; for ADLess deployments this is the customer-provided DNS zone name. .PARAMETER IsLocalIdentityEnvironment Routes the verdict mapping into the ADLess (anonymous DDNS) decision branch. .PARAMETER ClusterIP Optional expected cluster IP. When supplied, the function compares observed RDATA against it. When omitted, RdataMatchesClusterIp is reported as 'unknown' and the AD-Joined branch cannot return RED_WrongIp / GREEN_PreStaged. #> [CmdletBinding()] param ( [Parameter(Mandatory = $false)] [System.Management.Automation.Runspaces.PSSession[]] $PsSession, [Parameter(Mandatory = $false)] [string]$ClusterName, [Parameter(Mandatory = $false)] [string]$DomainFQDN, [Parameter(Mandatory = $false)] [switch]$IsLocalIdentityEnvironment, [Parameter(Mandatory = $false)] [string]$ClusterIP ) # SDP Phase 1: this posture probe is INFORMATIONAL and non-blocking. An unexpected internal # error must never break a deployment, so the whole body is guarded. On error we emit an # explicit result (mirroring Test-IPMapForCNO) so the failed probe is visible and actionable # rather than silently absent, and the caught exception is captured in telemetry to gate the # future INFORMATIONAL -> WARNING/CRITICAL promotion. try { $description = 'Read-only probe of the authoritative DNS server(s) for the deployment-target zone. Predicts whether the Failover Cluster CNO will be able to write its DNS A record at bring-online, distinguishing AD-Joined Kerberos identity and ADLess anonymous identity paths. Does not write to DNS.' $displayName = 'Cluster DNS Posture (CNO write feasibility)' # ---- Short-circuit: missing required inputs if ([string]::IsNullOrWhiteSpace($ClusterName) -or [string]::IsNullOrWhiteSpace($DomainFQDN)) { $skipDetail = "Verdict : SKIP_MissingInput`nReason : $($ldTxt.ClusterDnsPosture_SKIP_MissingInput)`nClusterName : $(if ($ClusterName) { $ClusterName } else { '<missing>' })`nDomainFQDN : $(if ($DomainFQDN) { $DomainFQDN } else { '<missing>' })" Log-Info 'Test-ClusterDnsPosture: skipped (ClusterName or DomainFQDN not supplied)' $skipResult = New-LightweightResult ` -Name 'AzStackHci_DNS_Test_ClusterDnsPosture' ` -Status 'SUCCESS' ` -Severity 'INFORMATIONAL' ` -TargetResourceName "$env:COMPUTERNAME/SKIP" ` -Source $env:COMPUTERNAME ` -Resource 'DNS' ` -Detail $skipDetail return @(New-AggregatedTestResult ` -TestName 'Test-ClusterDnsPosture' ` -DisplayName $displayName ` -Description $description ` -DetailResults @($skipResult) ` -ValidatorName 'DNS' ` -ResourceType 'DNS' ` -Remediation $ldTxt.ClusterDnsPostureRemediation) } # Stage 1 probe scriptblock: SOA + NS list + NS IP resolution. # Runs via PsSession so zone resolution reflects the node's network perspective. # Returns a structured hashtable: Skip=$false with NsRows, or Skip=$true with SkipReason. $probeSb = { param($zoneName, $clusterShort) $clusterFqdn = "$clusterShort.$zoneName" $soaPrimary = $null $soaResolved = $false try { $soa = Resolve-DnsName -Name $zoneName -Type SOA -DnsOnly -QuickTimeout -ErrorAction Stop $soaRecord = $soa | Select-Object -First 1 if ($soaRecord.PrimaryServer) { $soaPrimary = $soaRecord.PrimaryServer } $soaResolved = $true } catch { $soaResolved = $false } if (-not $soaResolved) { return @{ Skip = $true Verdict = 'SKIP_NoZone' ZoneName = $zoneName ClusterFqdn = $clusterFqdn Source = $env:COMPUTERNAME SkipReason = 'SOA query failed for zone' } } $authoritativeNs = @() try { $nsRecords = Resolve-DnsName -Name $zoneName -Type NS -DnsOnly -QuickTimeout -ErrorAction Stop $authoritativeNs = @($nsRecords | ForEach-Object { $_.NameHost } | Where-Object { $_ } | Sort-Object -Unique) } catch { $authoritativeNs = @() } if ($authoritativeNs.Count -eq 0) { return @{ Skip = $true Verdict = 'SKIP_NoZone' ZoneName = $zoneName ClusterFqdn = $clusterFqdn Source = $env:COMPUTERNAME SkipReason = 'NS query returned no authoritative servers' } } # Cap the authoritative-NS fan-out. Replica parity across a handful of name # servers is sufficient signal, and this bounds the probe cost in large AD # domains (100+ DCs). Sort-Object above makes the selection deterministic. $maxNsToProbe = 5 if ($authoritativeNs.Count -gt $maxNsToProbe) { $authoritativeNs = @($authoritativeNs | Select-Object -First $maxNsToProbe) } # NS IP resolution uses the node's local resolver; results become Stage 2 input. # QuickTimeout so stale NS glue cannot stall the probe. $nsRows = @() foreach ($ns in $authoritativeNs) { $row = @{ NameServer = $ns; NsIp = $null; Err = $null } try { $nsA = Resolve-DnsName -Name $ns -Type A -DnsOnly -QuickTimeout -ErrorAction Stop $nsIpRecord = $nsA | Where-Object { $_.IPAddress } | Select-Object -First 1 if ($nsIpRecord) { $row.NsIp = [string]$nsIpRecord.IPAddress } } catch { $row.Err = "NS IP lookup failed: $($_.Exception.Message)" } $nsRows += $row } return @{ Skip = $false Source = $env:COMPUTERNAME SoaPrimary = $soaPrimary ZoneName = $zoneName ClusterFqdn = $clusterFqdn AuthoritativeNs = $authoritativeNs NsRows = $nsRows } } # Execute Stage 1 via PsSession or locally. # A single Stage 1 probe is sufficient: DNS zone config is cluster-wide. $isLocalBool = [bool]$IsLocalIdentityEnvironment if ($PsSession) { $stage1 = Invoke-Command -Session $PsSession[0] -ScriptBlock $probeSb -ArgumentList $DomainFQDN, $ClusterName } else { $stage1 = Invoke-Command -ScriptBlock $probeSb -ArgumentList $DomainFQDN, $ClusterName } # Handle zone-resolution failure early if ($stage1.Skip) { $skipDetail = "Verdict : $($stage1.Verdict)`nZoneName : $($stage1.ZoneName)`nClusterFqdn : $($stage1.ClusterFqdn)`nReason : $($stage1.SkipReason)" Log-Info 'Test-ClusterDnsPosture: zone not resolvable, emitting SKIP' $skipRow = New-LightweightResult ` -Name 'AzStackHci_DNS_Test_ClusterDnsPosture' ` -Status 'SUCCESS' ` -Severity 'INFORMATIONAL' ` -TargetResourceName "$($stage1.Source)/DNS" ` -Source $stage1.Source ` -Resource 'DNS' ` -Detail $skipDetail return @(New-AggregatedTestResult ` -TestName 'Test-ClusterDnsPosture' ` -DisplayName $displayName ` -Description $description ` -DetailResults @($skipRow) ` -ValidatorName 'DNS' ` -ResourceType 'DNS' ` -Remediation $ldTxt.ClusterDnsPostureRemediation) } $zoneName = $stage1.ZoneName $clusterFqdn = $stage1.ClusterFqdn $soaPrimary = $stage1.SoaPrimary $authoritativeNs = $stage1.AuthoritativeNs $nodeSource = $stage1.Source $clusterIpExpected = if ($ClusterIP) { $ClusterIP } else { $null } # Stage 2: per-NS reachability + cluster A-record query (direct, QuickTimeout, read-only). # A cheap SOA probe on the same NS disambiguates unreachable from record-absent on a miss. $perNs = @() foreach ($nsRow in $stage1.NsRows) { $row = @{ NameServer = $nsRow.NameServer NsIp = $nsRow.NsIp Reachable = $false RecordPresent = $null RdataObserved = $null Ttl = $null Err = $nsRow.Err } if ($nsRow.NsIp) { # Read-only prediction: query the cluster A record directly against this NS with # QuickTimeout. An NXDOMAIN / empty answer is a first-class "record absent" result # (the expected pre-deploy state for ADLess), NOT a retryable failure. This # deliberately does NOT route through TestDNSResolution, whose hardcoded 3x5s # deploy-oriented retry/sleep would add ~15s per name server on the absent path. $aRecords = @() try { $aRecords = @(Resolve-DnsName -Name $clusterFqdn -Type A -Server $nsRow.NsIp -DnsOnly -QuickTimeout -ErrorAction Stop | Where-Object { $_.IPAddress }) } catch { $aRecords = @() } if ($aRecords.Count -gt 0) { $row.Reachable = $true $row.RecordPresent = $true $row.RdataObserved = [string]($aRecords | Select-Object -First 1).IPAddress } else { # No A record returned. Disambiguate "NS unreachable" from "record absent" # with a single cheap SOA probe (QuickTimeout) against the same NS. try { $null = Resolve-DnsName -Name $zoneName -Type SOA -Server $nsRow.NsIp -DnsOnly -QuickTimeout -ErrorAction Stop $row.Reachable = $true $row.RecordPresent = $false } catch { $row.Reachable = $false if (-not $row.Err) { $row.Err = "Unreachable on Server $($nsRow.NsIp): $($_.Exception.Message)" } } } } $perNs += $row } # Stage 3: posture inference $reachable = @($perNs | Where-Object { $_.Reachable }) $allUnreachable = ($reachable.Count -eq 0) $presentRows = @($reachable | Where-Object { $_.RecordPresent }) $existingRecordPresent = ($presentRows.Count -gt 0 -and $presentRows.Count -eq $reachable.Count) $observedRdataSet = @($presentRows | ForEach-Object { $_.RdataObserved } | Sort-Object -Unique) $partialPresence = ($presentRows.Count -gt 0 -and $presentRows.Count -lt $reachable.Count) $allNsReplicaParity = (-not $partialPresence) -and ($observedRdataSet.Count -le 1) $zoneIsADIntegrated = $false $zoneConfidence = 'Low' if ($soaPrimary -and $authoritativeNs.Count -gt 0) { $primaryLc = $soaPrimary.ToString().ToLower().TrimEnd('.') $nsLc = $authoritativeNs | ForEach-Object { $_.ToString().ToLower().TrimEnd('.') } if ($primaryLc -in $nsLc) { $zoneIsADIntegrated = $true $zoneConfidence = 'High' } } $rdataMatchesClusterIp = 'unknown' if ($existingRecordPresent -and -not [string]::IsNullOrWhiteSpace($clusterIpExpected)) { if ($observedRdataSet.Count -eq 1 -and $observedRdataSet[0] -eq $clusterIpExpected) { $rdataMatchesClusterIp = 'True' } else { $rdataMatchesClusterIp = 'False' } } # Verdict mapping: delegates to Get-ClusterDnsPostureVerdict (pure function, separately unit-tested) $probeHt = @{ SoaResolved = $true AllNsUnreachable = $allUnreachable ExistingRecordPresent = $existingRecordPresent RdataMatchesClusterIp = $rdataMatchesClusterIp AllNsReplicaParity = $allNsReplicaParity ZoneIsADIntegrated = $zoneIsADIntegrated } $verdict = Get-ClusterDnsPostureVerdict -Probe $probeHt -IsLocalIdentity $isLocalBool $rowStatus = if ($verdict -like 'RED_*') { 'FAILURE' } elseif ($verdict -like 'AMBER_*') { 'WARNING' } else { 'SUCCESS' } $emitted = @() foreach ($r in $perNs) { $detailLines = @( "Verdict : $verdict", "ZoneName : $zoneName", "ClusterFqdn : $clusterFqdn", "NameServer : $($r.NameServer)", "NsIp : $(if ($r.NsIp) { $r.NsIp } else { '-' })", "SoaPrimaryNs : $soaPrimary", "ZoneIsADIntegrated : $zoneIsADIntegrated (confidence: $zoneConfidence)", "ExistingRecordPresent: $(if ($null -eq $r.RecordPresent) { 'unknown' } else { $r.RecordPresent })", "RdataObserved : $(if ($r.RdataObserved) { $r.RdataObserved } else { '-' })", "ExpectedClusterIp : $(if ($clusterIpExpected) { $clusterIpExpected } else { '<not supplied>' })", "RdataMatchesClusterIp: $rdataMatchesClusterIp", "AllNsReplicaParity : $allNsReplicaParity", "IsLocalIdentity : $isLocalBool" ) if ($r.Err) { $detailLines += "Error : $($r.Err)" } $emitted += @{ Source = $nodeSource Resource = $r.NameServer Status = $rowStatus Verdict = $verdict Detail = $detailLines -join "`n" } } foreach ($r in $emitted) { $logType = switch ($r.Status) { 'FAILURE' { 'Critical' } 'WARNING' { 'Warning' } default { 'INFO' } } $line = "Test-ClusterDnsPosture: {0} on {1} (NS={2}) -> {3}" -f $r.Verdict, $r.Source, $r.Resource, $r.Status Log-Info -Message $line -Type $logType } $detailResults = @() foreach ($r in $emitted) { $detailResults += New-LightweightResult ` -Name 'AzStackHci_DNS_Test_ClusterDnsPosture' ` -Status $r.Status ` -Severity 'INFORMATIONAL' ` -TargetResourceName "$($r.Source)/$($r.Resource)" ` -Source $r.Source ` -Resource $r.Resource ` -Detail $r.Detail } return @(New-AggregatedTestResult ` -TestName 'Test-ClusterDnsPosture' ` -DisplayName $displayName ` -Description $description ` -DetailResults $detailResults ` -ValidatorName 'DNS' ` -ResourceType 'DNS' ` -Remediation $ldTxt.ClusterDnsPostureRemediation) } catch { # A thrown error must not look like "passed". Emit an explicit FAILURE aggregated result # (INFORMATIONAL severity, non-blocking per SDP Phase 1) so the failed probe is visible and # actionable rather than silently absent. Logged as Warning because the check is advisory. $errorDetail = $ldTxt.ClusterDnsPostureUnexpectedError -f $_.Exception.Message Log-Info $errorDetail -Type Warning $errorResult = New-LightweightResult ` -Name 'AzStackHci_DNS_Test_ClusterDnsPosture' ` -Status 'FAILURE' ` -Severity 'INFORMATIONAL' ` -TargetResourceName "$env:COMPUTERNAME/DNS" ` -Source $env:COMPUTERNAME ` -Resource 'DNS' ` -Detail $errorDetail return @(New-AggregatedTestResult ` -TestName 'Test-ClusterDnsPosture' ` -DisplayName 'Cluster DNS Posture (CNO write feasibility)' ` -Description 'Read-only probe of the authoritative DNS server(s) for the deployment-target zone. Predicts whether the Failover Cluster CNO will be able to write its DNS A record at bring-online. Does not write to DNS.' ` -DetailResults @($errorResult) ` -ValidatorName 'DNS' ` -ResourceType 'DNS' ` -Remediation $ldTxt.ClusterDnsPostureRemediation) } } Export-ModuleMember -Function Test-* # SIG # Begin signature block # MIInKAYJKoZIhvcNAQcCoIInGTCCJxUCAQExDzANBglghkgBZQMEAgEFADB5Bgor # BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG # KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCC8A7g/jmdDeCSO # wy/nEJfItmjhJkzHHKXfloMVjPSAuaCCDLowggX1MIID3aADAgECAhMzAAACHU0Z # yE7XD1dIAAAAAAIdMA0GCSqGSIb3DQEBCwUAMFcxCzAJBgNVBAYTAlVTMR4wHAYD # VQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xKDAmBgNVBAMTH01pY3Jvc29mdCBD # b2RlIFNpZ25pbmcgUENBIDIwMjQwHhcNMjYwNDE2MTg1OTQzWhcNMjcwNDE1MTg1 # OTQzWjB0MQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UE # BxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMR4wHAYD # VQQDExVNaWNyb3NvZnQgQ29ycG9yYXRpb24wggEiMA0GCSqGSIb3DQEBAQUAA4IB # DwAwggEKAoIBAQDQvewXxx9gZZFC6Ys1WBay8BJ8kGA4JQnH5CMafqOASlTpK9H8 # o5ZXTXt0caVQTNMUPt445wXYD+dFtaKWTwDn1I52oUSrC9vJin1Gsqt+zyKJL5Dg # 3eQXbQNR61DmMy20GLTIO3SFed9Rfi/ophgCLGFLDR3r0KvHjwMb/jYWS0celV/4 # Lz27LfAekm8v9E5IXaeiXbAUYZKK090n4CVl3JBtbN+9DtI9SNu/yjvozW52/u7R # X/Ttpa/KDlpuokZ+Zcbvmtd9ur9gFLvZzh41o9MsE/clQtdaFWGvuo6Jua/ntpgk # ey3E5/vBFe+MJPG6phdnuo6r57ZudCudiI1bAgMBAAGjggGbMIIBlzAOBgNVHQ8B # Af8EBAMCB4AwHwYDVR0lBBgwFgYKKwYBBAGCN0wIAQYIKwYBBQUHAwMwHQYDVR0O # BBYEFH6QuMwqcPG0hQlQ6c5jCtTTLrVeMEUGA1UdEQQ+MDykOjA4MR4wHAYDVQQL # ExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xFjAUBgNVBAUTDTIzMDAxMis1MDc1NTkw # HwYDVR0jBBgwFoAUf1k/VCHarU/vBeXmo9ctBpQSCDEwYAYDVR0fBFkwVzBVoFOg # UYZPaHR0cDovL3d3dy5taWNyb3NvZnQuY29tL3BraW9wcy9jcmwvTWljcm9zb2Z0 # JTIwQ29kZSUyMFNpZ25pbmclMjBQQ0ElMjAyMDI0LmNybDBtBggrBgEFBQcBAQRh # MF8wXQYIKwYBBQUHMAKGUWh0dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2lvcHMv # Y2VydHMvTWljcm9zb2Z0JTIwQ29kZSUyMFNpZ25pbmclMjBQQ0ElMjAyMDI0LmNy # dDAMBgNVHRMBAf8EAjAAMA0GCSqGSIb3DQEBCwUAA4ICAQBKTbYOjzwTG/DXGaz9 # s6+fQeaTtDcFmMY+5UyVFCyj7Pv+5i37qfX8lSL/tBIfYQfWsMuBQlfZurJD6r4H # VJ2CeH+1fgiq8dcHdVKoZ3Sa2qXoX3cq9iS8cVb06B7+5/XJ7I0OxHH9fDsvJ3T3 # w5V/ZtAIFmLrl+P0CtG+92uzRsn0nTbdFjOkLMLWPLAU3THohKRlSEMgFJpPkm5n # 5UAZ35xX6FWCrDLsSKb555bTifwa8mJBwdlof0bmfYidH+dxZ1FdDxvLnNl9zeKs # A4kejaaIqqIPguhwAti5Ql7BlTNoJNwxCvBmqW2MQLnCkYN/VVUsR3V2x/rcTNzo # Bf/Z/SpROvdaA2ZOOd1uioXJt3tdLQ7vHpqpib0KfWr/FWXW10q38VxfCnRQBqzb # SuztR7nEMuzX7Ck+B/XaPDXd1qh72+QYyB0Z2VzWmO9zsnb9Uq/dwu8LGeQqnyu6 # 7SDGACvnXii2fb9+US492VTnXSnFKyqwgzUyFMtZK1/sHYTv6bG4TtQUygQxTN+Z # V+aJIlKO2MqZ7bKrAnOzS9m6NgoTdWOq11bTOZwKlIEV/EhV9SWkDmdpR/hPPT2v # 6TEj4F8PT/zHjRezIU5c/DGlt/VhY/pK0XkJtEyMmmS1BMtjU/rqBZVMIm3dnxQs # /TBByr+Cf8Z1r7aifQVQ+WSqzjCCBr0wggSloAMCAQICEzMAAAA5O7Y3Gb8GHWcA # AAAAADkwDQYJKoZIhvcNAQEMBQAwgYgxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpX # YXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNyb3NvZnQg # Q29ycG9yYXRpb24xMjAwBgNVBAMTKU1pY3Jvc29mdCBSb290IENlcnRpZmljYXRl # IEF1dGhvcml0eSAyMDExMB4XDTI0MDgwODIwNTQxOFoXDTM2MDMyMjIyMTMwNFow # VzELMAkGA1UEBhMCVVMxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEo # MCYGA1UEAxMfTWljcm9zb2Z0IENvZGUgU2lnbmluZyBQQ0EgMjAyNDCCAiIwDQYJ # KoZIhvcNAQEBBQADggIPADCCAgoCggIBANgBnB7jOMeqlRYHNa265v4IY9fH8TKh # emHfPINe1gpLaV3dhg324WwH06LcHbpnsBukCDNitryo0dtS/EW6I/yEL/bLSY8h # KpbfQuWusBPr9qazYcDxCW/qnjb5JsI1s8bNOg3bVATvQVL4tcf03aTycsz8QeCd # M0l/yHRObJ9QqazM1r6VPEOJ7LL+uEEb73w6QCuhs89a1uv1zerOYMnsneRRwCbp # yW11IcggU0cRKDDq1pjVJzIbIF6+oiXXbReOsgeI8zu1FyQfK0fVkaya8SmVHQ/t # Of23mZ4W9k0Ri22QW9p3UgSC5OUDktKxxcCmGL6tXLfOGSWHIIV4YrTJTT6PNty5 # REojHJuZHArkF9VnHTERWoTjAzfI3kP+5b4alUdhgAZ7ttOu1bVnXfHaqPYl2rPs # 20ji03LOVWsh/radgE17es5hL+t6lV0eVHrVhsssROWJuz2MXMCt7iw7lFPG9LXK # Gjsmonn2gotGdHIuEg5JnJMJVmixd5LRlkmgYRZKzhxSCwyoGIq0PhaA7Y+VPct5 # pCHkijcIIDm0nlkK+0KyepolcqGm0T/GYQRMhHJlGOOmVQop36wUVUYklUy++vDW # eEgEo4s7hxN6mIbf2MSIQ/iIfMZgJxC69oukMUXCrOC3SkE/xIkgpfl22MM1itkZ # 35nNXkMolU1lAgMBAAGjggFOMIIBSjAOBgNVHQ8BAf8EBAMCAYYwEAYJKwYBBAGC # NxUBBAMCAQAwHQYDVR0OBBYEFH9ZP1Qh2q1P7wXl5qPXLQaUEggxMBkGCSsGAQQB # gjcUAgQMHgoAUwB1AGIAQwBBMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAU # ci06AjGQQ7kUBU7h6qfHMdEjiTQwWgYDVR0fBFMwUTBPoE2gS4ZJaHR0cDovL2Ny # bC5taWNyb3NvZnQuY29tL3BraS9jcmwvcHJvZHVjdHMvTWljUm9vQ2VyQXV0MjAx # MV8yMDExXzAzXzIyLmNybDBeBggrBgEFBQcBAQRSMFAwTgYIKwYBBQUHMAKGQmh0 # dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2kvY2VydHMvTWljUm9vQ2VyQXV0MjAx # MV8yMDExXzAzXzIyLmNydDANBgkqhkiG9w0BAQwFAAOCAgEAFJQfOChP7onn6fLI # MKrSlN1WYKwDFgAddymOUO3FrM8d7B/W/iQ6DxXsDn7D5W4wMwYeLystcEqfkjz4 # NURRgazyMu5yRzQh4LqjA4tStTcJh1opExo7nn5PuPBYnbu0+THSuVHTe0VTTPVh # ily/piFrDo3axQ9P4C+Ol5yet+2gTfekICS5xS+cYfSIvgn0JksVBVMYVI5QFu/q # hnLhsEFEUzG8fvv0hjgkO+lkpV9ty6GkN4vdnd7ya6Q6aR9y34aiM1qmxaxBi6OU # nyNl6fkuun/diTFnYDLTppOkr/mg5WSfCiDVMNCxtj4wPKC5OmHm1DQIt/MNokbb # H3UGsFP1QbzsLocuSqLCvH09Io3fDPTmscR9Y75G4qX7RTX8AdBPo0I6OEojf39z # uFZt0qOHm65YWQE69cZM2ueE1MB05dNNgHK9gTE7zKvK/fg8B2qjW88MT/WF5V5u # vZGtqa9FSL2RazArA+rDPuf6JGYz4HpgMZHB4S6szWSKYBv0VisCzfxgeU+dquXW # 9bd0auYlOB58DPcOYKdc3Se94g+xL4pcEhbB54JOgAkwYTu/9dLeH2pDqeJZAABV # DWRQCaXfO5LgyKwKCLYXpigrZYCjUSBcr+Ve8PFWMhVTQl0v4q8J/AUmQN5W4n10 # 1cY2L4A7GTQG1h32HHAvfQESWP0xghnEMIIZwAIBATBuMFcxCzAJBgNVBAYTAlVT # MR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xKDAmBgNVBAMTH01pY3Jv # c29mdCBDb2RlIFNpZ25pbmcgUENBIDIwMjQCEzMAAAIdTRnITtcPV0gAAAAAAh0w # DQYJYIZIAWUDBAIBBQCggZAwGQYJKoZIhvcNAQkDMQwGCisGAQQBgjcCAQQwLwYJ # KoZIhvcNAQkEMSIEIAuTFe0zJF8EA2O5Ydb0KCWwDJs82f87SwUCvtfNyxhuMEIG # CisGAQQBgjcCAQwxNDAyoBSAEgBNAGkAYwByAG8AcwBvAGYAdKEagBhodHRwOi8v # d3d3Lm1pY3Jvc29mdC5jb20wDQYJKoZIhvcNAQEBBQAEggEAZQ3AVnRXBW/uPefL # WfDh3IfszTP4etx8hP6rlgbFiM79H4gxMxo9ebF+K7us+ijCBMzCRSvgGkEOvpkI # azO1pewr2fQwLNdnfYrJE8cRf22v4r2O1TfscP2ptC005Sojn56lhcBH5HCW7iyh # 1LlwKXn5++2Qt7cO/QNrHoDUpVksaFLV7LpYL3VruD089nXTAwH/RbWofS7ywTQX # fGif4iHll0tSKJmoTppX3yew17bwY7bw0LFVjyBaTFgy0kKVyER7b50T2ssAQCWA # Lmyo3m3FhWgdjphNu2/AD0jUCcrAxk47uXoFnubihfQfs3yh6dQANNsSh2VAmc7s # DsNnbqGCF5QwgheQBgorBgEEAYI3AwMBMYIXgDCCF3wGCSqGSIb3DQEHAqCCF20w # ghdpAgEDMQ8wDQYJYIZIAWUDBAIBBQAwggFSBgsqhkiG9w0BCRABBKCCAUEEggE9 # MIIBOQIBAQYKKwYBBAGEWQoDATAxMA0GCWCGSAFlAwQCAQUABCDEAen3qOCpkT0w # j/k1ALiry5BALoGJllgYATRT378EewIGaoVZBxBwGBMyMDI2MDgyNzE2Mzg1MC40 # MTlaMASAAgH0oIHRpIHOMIHLMQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGlu # Z3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBv # cmF0aW9uMSUwIwYDVQQLExxNaWNyb3NvZnQgQW1lcmljYSBPcGVyYXRpb25zMScw # JQYDVQQLEx5uU2hpZWxkIFRTUyBFU046OTYwMC0wNUUwLUQ5NDcxJTAjBgNVBAMT # HE1pY3Jvc29mdCBUaW1lLVN0YW1wIFNlcnZpY2WgghHqMIIHIDCCBQigAwIBAgIT # MwAAAiY1tD5nQ5P2HwABAAACJjANBgkqhkiG9w0BAQsFADB8MQswCQYDVQQGEwJV # UzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UE # ChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMSYwJAYDVQQDEx1NaWNyb3NvZnQgVGlt # ZS1TdGFtcCBQQ0EgMjAxMDAeFw0yNjAyMTkxOTQwMDJaFw0yNzA1MTcxOTQwMDJa # MIHLMQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMH # UmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMSUwIwYDVQQL # ExxNaWNyb3NvZnQgQW1lcmljYSBPcGVyYXRpb25zMScwJQYDVQQLEx5uU2hpZWxk # IFRTUyBFU046OTYwMC0wNUUwLUQ5NDcxJTAjBgNVBAMTHE1pY3Jvc29mdCBUaW1l # LVN0YW1wIFNlcnZpY2UwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC/ # /w+ZZIL5RFFpVI8D3ZyuNu8IzcAEOD30OLYjh337rXjcrIlOSzpJc4ZeUxEyli6x # 6F6zm4NR8dbPb9diDp/hOUzHWGxiA1Z3RXKBb/4F/ojyvN43SEGWqSfVc3I3BlsY # T35ecVAJ9kVf90YOv29tFjJBBZkYvrT/DwwyRLscOyP4p+9/lyJjD+ULs3YXBhVr # fZ+MbQB+BYKLqRvBKbj/wR9akNrMxQINoGaD5jZO/N/nSsmG2P1zv/cv4gSoMBnW # eQIBkjd2I5w1DeXupp2vSiNmR5sA2ZkBK3yiQWaJvRxODlkfiyHk9Mkk/TrYTjmj # PCbhe+uqhHNRy8UlbOvWsCq0tRtUykHv39DgqAfJNrE8OSt835rBzDprrcAhwmgf # hoVi4AKeqwikY0nUa48K0Qy80XT4fiEA3ExEZNaRFo9Nq/GwbfgqKqGmc9xhKuRF # cjtua4KHZvnAvpWgEFSOCkovXs/BcLnkEHM9xZ8iUag5CyhNqXYYE/z0pcXdYaNI # kQ68EWmuvLm7g9oofV2vOm5GVNoghnkWG6nGPo/JwEgmA9oSS0EfvFRMWPA/gpSv # F3shArKHnaEpVSSi3DNbyiuYiEs9Ko0IkZc8xKFeQRaqGRxrB+2r/7B3X81Tps99 # KhFwg+wD87od22F2MUg1x7twt3gaVnFk0IZIwUPCGwIDAQABo4IBSTCCAUUwHQYD # VR0OBBYEFF3hn9fYJN2Y/Z9LVbBPIxAzXHsQMB8GA1UdIwQYMBaAFJ+nFV0AXmJd # g/Tl0mWnG1M1GelyMF8GA1UdHwRYMFYwVKBSoFCGTmh0dHA6Ly93d3cubWljcm9z # b2Z0LmNvbS9wa2lvcHMvY3JsL01pY3Jvc29mdCUyMFRpbWUtU3RhbXAlMjBQQ0El # MjAyMDEwKDEpLmNybDBsBggrBgEFBQcBAQRgMF4wXAYIKwYBBQUHMAKGUGh0dHA6 # Ly93d3cubWljcm9zb2Z0LmNvbS9wa2lvcHMvY2VydHMvTWljcm9zb2Z0JTIwVGlt # ZS1TdGFtcCUyMFBDQSUyMDIwMTAoMSkuY3J0MAwGA1UdEwEB/wQCMAAwFgYDVR0l # AQH/BAwwCgYIKwYBBQUHAwgwDgYDVR0PAQH/BAQDAgeAMA0GCSqGSIb3DQEBCwUA # A4ICAQA2Ux0tr9sYCjsq0FRyiVpx15OurNXv6Qk7iX+ArVPlz3w4tqjcTNm1dt3t # Tua2wJMpJhPH8n7UXhmT98d5Du44Ll4adnse4SQfVg3QL6aRkXHnJUn8y9iftB/P # y22n9xnwPFfj3QlDOSgLuHleu97U0iH2ZaluYabWXJihdiYpK8cPHFlqZOAiot0+ # GD8dP+RMuvpxt/F2LmYelpoZwriiFOUmlxEUV7xJHyZZlDquskeyuq01DTv91N4q # M8cfPPhl/2pc4HeMf/nd2HouifJbDQFNd4WPhLzn0Sy3u1Zh3+S3tjQdqN+dyw60 # RaV+RXCoOLgFZ3MAg/GoDl+fvb5hy/1a71ctX8wEad1Pf6def2pqfl3wFc++hkF8 # DXXTZofJN4YVaN3InwbAGQDDkNK4lqecCixxmSKwidPynGeE5OtvNoK1pkLsm/i8 # F1RjGczZ/kSF2VDkqG866iQ+jVbGOQ6Du3eyyFcFKZoDJ4B5mEAS9aT2SKqllLey # bOboH6r67siR5B/2Hnu7+KYuYZy0BEadtA6ngG4cnSR9JsrkhhsKmb11ujqwgJyN # x92MsoGGwNgN1aI0QID8CsjCFwpfmMzlA44xHKYv3hmjxeqBS4uU5rQeiAnVgpJe # aVGKm/lzPDtnppGV+7XhRp5b1ZxT/Z7Xxc+I7H7/jCtQDZoaZTCCB3EwggVZoAMC # AQICEzMAAAAVxedrngKbSZkAAAAAABUwDQYJKoZIhvcNAQELBQAwgYgxCzAJBgNV # BAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4w # HAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xMjAwBgNVBAMTKU1pY3Jvc29m # dCBSb290IENlcnRpZmljYXRlIEF1dGhvcml0eSAyMDEwMB4XDTIxMDkzMDE4MjIy # NVoXDTMwMDkzMDE4MzIyNVowfDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldhc2hp # bmd0b24xEDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jw # b3JhdGlvbjEmMCQGA1UEAxMdTWljcm9zb2Z0IFRpbWUtU3RhbXAgUENBIDIwMTAw # ggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDk4aZM57RyIQt5osvXJHm9 # DtWC0/3unAcH0qlsTnXIyjVX9gF/bErg4r25PhdgM/9cT8dm95VTcVrifkpa/rg2 # Z4VGIwy1jRPPdzLAEBjoYH1qUoNEt6aORmsHFPPFdvWGUNzBRMhxXFExN6AKOG6N # 7dcP2CZTfDlhAnrEqv1yaa8dq6z2Nr41JmTamDu6GnszrYBbfowQHJ1S/rboYiXc # ag/PXfT+jlPP1uyFVk3v3byNpOORj7I5LFGc6XBpDco2LXCOMcg1KL3jtIckw+DJ # j361VI/c+gVVmG1oO5pGve2krnopN6zL64NF50ZuyjLVwIYwXE8s4mKyzbnijYjk # lqwBSru+cakXW2dg3viSkR4dPf0gz3N9QZpGdc3EXzTdEonW/aUgfX782Z5F37Zy # L9t9X4C626p+Nuw2TPYrbqgSUei/BQOj0XOmTTd0lBw0gg/wEPK3Rxjtp+iZfD9M # 269ewvPV2HM9Q07BMzlMjgK8QmguEOqEUUbi0b1qGFphAXPKZ6Je1yh2AuIzGHLX # pyDwwvoSCtdjbwzJNmSLW6CmgyFdXzB0kZSU2LlQ+QuJYfM2BjUYhEfb3BvR/bLU # HMVr9lxSUV0S2yW6r1AFemzFER1y7435UsSFF5PAPBXbGjfHCBUYP3irRbb1Hode # 2o+eFnJpxq57t7c+auIurQIDAQABo4IB3TCCAdkwEgYJKwYBBAGCNxUBBAUCAwEA # ATAjBgkrBgEEAYI3FQIEFgQUKqdS/mTEmr6CkTxGNSnPEP8vBO4wHQYDVR0OBBYE # FJ+nFV0AXmJdg/Tl0mWnG1M1GelyMFwGA1UdIARVMFMwUQYMKwYBBAGCN0yDfQEB # MEEwPwYIKwYBBQUHAgEWM2h0dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2lvcHMv # RG9jcy9SZXBvc2l0b3J5Lmh0bTATBgNVHSUEDDAKBggrBgEFBQcDCDAZBgkrBgEE # AYI3FAIEDB4KAFMAdQBiAEMAQTALBgNVHQ8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB # /zAfBgNVHSMEGDAWgBTV9lbLj+iiXGJo0T2UkFvXzpoYxDBWBgNVHR8ETzBNMEug # SaBHhkVodHRwOi8vY3JsLm1pY3Jvc29mdC5jb20vcGtpL2NybC9wcm9kdWN0cy9N # aWNSb29DZXJBdXRfMjAxMC0wNi0yMy5jcmwwWgYIKwYBBQUHAQEETjBMMEoGCCsG # AQUFBzAChj5odHRwOi8vd3d3Lm1pY3Jvc29mdC5jb20vcGtpL2NlcnRzL01pY1Jv # b0NlckF1dF8yMDEwLTA2LTIzLmNydDANBgkqhkiG9w0BAQsFAAOCAgEAnVV9/Cqt # 4SwfZwExJFvhnnJL/Klv6lwUtj5OR2R4sQaTlz0xM7U518JxNj/aZGx80HU5bbsP # MeTCj/ts0aGUGCLu6WZnOlNN3Zi6th542DYunKmCVgADsAW+iehp4LoJ7nvfam++ # Kctu2D9IdQHZGN5tggz1bSNU5HhTdSRXud2f8449xvNo32X2pFaq95W2KFUn0CS9 # QKC/GbYSEhFdPSfgQJY4rPf5KYnDvBewVIVCs/wMnosZiefwC2qBwoEZQhlSdYo2 # wh3DYXMuLGt7bj8sCXgU6ZGyqVvfSaN0DLzskYDSPeZKPmY7T7uG+jIa2Zb0j/aR # AfbOxnT99kxybxCrdTDFNLB62FD+CljdQDzHVG2dY3RILLFORy3BFARxv2T5JL5z # bcqOCb2zAVdJVGTZc9d/HltEAY5aGZFrDZ+kKNxnGSgkujhLmm77IVRrakURR6nx # t67I6IleT53S0Ex2tVdUCbFpAUR+fKFhbHP+CrvsQWY9af3LwUFJfn6Tvsv4O+S3 # Fb+0zj6lMVGEvL8CwYKiexcdFYmNcP7ntdAoGokLjzbaukz5m/8K6TT4JDVnK+AN # uOaMmdbhIurwJ0I9JZTmdHRbatGePu1+oDEzfbzL6Xu/OHBE0ZDxyKs6ijoIYn/Z # cGNTTY3ugm2lBRDBcQZqELQdVTNYs6FwZvKhggNNMIICNQIBATCB+aGB0aSBzjCB # yzELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1Jl # ZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjElMCMGA1UECxMc # TWljcm9zb2Z0IEFtZXJpY2EgT3BlcmF0aW9uczEnMCUGA1UECxMeblNoaWVsZCBU # U1MgRVNOOjk2MDAtMDVFMC1EOTQ3MSUwIwYDVQQDExxNaWNyb3NvZnQgVGltZS1T # dGFtcCBTZXJ2aWNloiMKAQEwBwYFKw4DAhoDFQCi/fMxFtkqr7XMXdsRyWU0lSKH # Z6CBgzCBgKR+MHwxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAw # DgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24x # JjAkBgNVBAMTHU1pY3Jvc29mdCBUaW1lLVN0YW1wIFBDQSAyMDEwMA0GCSqGSIb3 # DQEBCwUAAgUA7jpiKjAiGA8yMDI2MDgyNzA3MTM0NloYDzIwMjYwODI4MDcxMzQ2 # WjB0MDoGCisGAQQBhFkKBAExLDAqMAoCBQDuOmIqAgEAMAcCAQACAhVpMAcCAQAC # AhPjMAoCBQDuO7OqAgEAMDYGCisGAQQBhFkKBAIxKDAmMAwGCisGAQQBhFkKAwKg # CjAIAgEAAgMHoSChCjAIAgEAAgMBhqAwDQYJKoZIhvcNAQELBQADggEBACmZ5BpF # 5VzLTbuseoKUd9DxZIgATFOdA1KGiVETIjDPBAxV3S0u1hNWJJzzEQU6jikRpR+N # cWikOUxzy3T6XQ+//M89XY2Gt7rm2TXMC0w4gahEVeafEPPJxzLWuc/tFAbv9TvZ # h2V9g4RhLX9SrOPqWCwLoV/lKIjcpKt9F883ojAKEjwocBuD9LQxcQgKdqQgMKaV # TM0mL5OF6E+ls9jCFDg3kJgBFCheYZxcUnIbsL6H223FSaCh3UJRtKgC/9bnHUWH # VZvwXL7q5+MofdYYVsrbSsjy65K0ZI6wEeVlBlzMV8rKidGrb3xf9h0AVUI4ibvA # lQSoblSGXu9bKQ4xggQNMIIECQIBATCBkzB8MQswCQYDVQQGEwJVUzETMBEGA1UE # CBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9z # b2Z0IENvcnBvcmF0aW9uMSYwJAYDVQQDEx1NaWNyb3NvZnQgVGltZS1TdGFtcCBQ # Q0EgMjAxMAITMwAAAiY1tD5nQ5P2HwABAAACJjANBglghkgBZQMEAgEFAKCCAUow # GgYJKoZIhvcNAQkDMQ0GCyqGSIb3DQEJEAEEMC8GCSqGSIb3DQEJBDEiBCAXXmfG # WUnjizNEtJlI1cjYe2vJhcsggRCyJ3RMcBbK8TCB+gYLKoZIhvcNAQkQAi8xgeow # gecwgeQwgb0EIMwyXGFnTNsZRBrs6GN/BbV0okaNP3VBYqLFjUsFnbgqMIGYMIGA # pH4wfDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcT # B1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEmMCQGA1UE # AxMdTWljcm9zb2Z0IFRpbWUtU3RhbXAgUENBIDIwMTACEzMAAAImNbQ+Z0OT9h8A # AQAAAiYwIgQgC4my0mwzs7AZeVA2HlUzT7nZYwn0tZrZw5csNeOFNW4wDQYJKoZI # hvcNAQELBQAEggIAszMGnQrPXkydT2XVzhlxtg5zN7+gtCcYtWKQIbKh9wK1fS7o # 0nMdKHDjlGasu0GO0oxt+Opod/ceK66+gi4zLnYGTzObq4oqongMGvC7wSOitVLH # YFMfns+iVQmWpjprd9nlyPDdXiB4sdR/C6xPDKstQncOv2OwSh6GzVDP1uc8qOpL # lP9bcrn0zvH9xs34E1t4pQ4JeIiBdnsVwQrMlM7Mtdk26B6ULuEIDIgxGGYmiHzq # zrVGxDQ63/uQIvtuj/UpAu4dZulEfpPgkA0fSe4vuR2RqWSJePeFwZa9TImYxCYe # gCv7PxakTDBSVpP9rSFEKjdZNhjZLpmfboQGn4p9UVG4fEpR9FSN3pg9hVrrAXyf # 6+bTGjLV+hZy+yJ/HZpPwgfI5dpqN9jYB1t5aq1LZyT3v42t/+/sRoYpFOCZFbum # tcUj86U9eriJgx66bjPosOOg/SupApwao/b7yrBq7bJRtGhTOUpqlOrhgpLumXgY # LxA2TLdBMs4XoB/3CPIBWaHbN3yO8rR8d/ULDkOMbD70GukQKH+r8PlQ7MayqDKl # kS1p7rSv6im+Mq2vN4E96xNmfQqP04lnpieDJcKKiFGC5KuiU8cVT6vdIHKYfXDK # 84u8XgWnxfrdYzMS9qCbtIbrQvxowWdWytJz37eF8JZGZYYFrvqZq+oT7kc= # SIG # End signature block |