AksGMSA.psm1
$ErrorActionPreference = "Stop" function Install-ToolingRequirements { <# .SYNOPSIS Install the tooling requirements. .PARAMETER KubectlVersion The release version of the kubectl tool. Valid values for this parameter are release tags from: https://github.com/kubernetes/kubernetes/releases. #> [CmdletBinding()] Param( [String]$KubectlVersion="latest" ) PROCESS { Write-Output "Installing the tooling requirements." # Install Azure CLI, if it's not found in PATH. try { Assert-FoundInPath "az" Write-Verbose "The tool 'az' already found in PATH. Skipping installation." } catch [System.Exception] { Write-Verbose "Installing Azure CLI." $installerPath = Join-Path $env:TEMP "AzureCLI.msi" Start-FileDownload -URL "https://aka.ms/installazurecliwindows" -Destination $installerPath $p = Start-Process -FilePath "msiexec.exe" -ArgumentList @("/I", $installerPath, "/quiet") -Wait -PassThru if($p.ExitCode) { Throw "Failed to install Azure CLI. Exit code: $($p.ExitCode)" } Remove-Item -Force -Path $installerPath Add-ToPathEnvVar -Path "${env:ProgramFiles(x86)}\Microsoft SDKs\Azure\CLI2\wbin" -Target ([System.EnvironmentVariableTarget]::Machine) } # Install required PowerShell modules, if not installed already. $powerShellModules = @( @{ "Name" = "Az.Accounts" "RequiredVersion" = [System.Version]::new(2, 2, 8) }, @{ "Name" = "Az.Resources" "RequiredVersion" = [System.Version]::new(4, 1, 0) } @{ "Name" = "Az.Aks" "RequiredVersion" = [System.Version]::new(2, 1, 0) }, @{ "Name" = "Az.Compute" "RequiredVersion" = [System.Version]::new(4, 12, 0) }, @{ "Name" = "Az.ManagedServiceIdentity" "RequiredVersion" = [System.Version]::new(0, 7, 3) }, @{ "Name" = "Az.KeyVault" "RequiredVersion" = [System.Version]::new(3, 4, 3) } ) Install-PackageProvider -Name "NuGet" -Force -Confirm:$false | Out-Null foreach ($m in $powerShellModules) { try { $module = Start-Job ` -ScriptBlock { Param($Name) Import-Module $Name -PassThru } ` -ArgumentList $m["Name"] | Receive-Job -Wait if($module.Version -ne $m["RequiredVersion"]) { Throw "Need to install PowerShell module '$($m["Name"])' version '$($m["RequiredVersion"])'." } Write-Verbose "PowerShell module '$($m["Name"])' version '$($m["RequiredVersion"])' already installed." } catch [System.Exception] { Write-Verbose "Installing PowerShell module '$($m["Name"])' version '$($m["RequiredVersion"])'." Install-Module -Name $m["Name"] -RequiredVersion $m["RequiredVersion"] -Force -AllowClobber -Confirm:$false } } # Install kubectl, if it's not found in PATH. try { Assert-FoundInPath "kubectl" Write-Verbose "The tool 'kubectl' already found in PATH. Skipping installation." } catch [System.Exception] { $dest = Join-Path $HOME ".azure-kubectl" Write-Verbose "Installing ${KubectlVersion} kubectl to ${dest}." Install-AzAksKubectl -Destination $dest -Version $KubectlVersion -Force -Confirm:$false Add-ToPathEnvVar -Path $dest -Target ([System.EnvironmentVariableTarget]::User) } # Install Active Directory RSAT (Remote Server Administration Tools), if not installed already. try { Start-Job -ScriptBlock { Import-Module "ActiveDirectory" } | Receive-Job -Wait Write-Verbose "PowerShell module 'ActiveDirectory' already installed." } catch [System.Exception] { Write-Verbose "Installing PowerShell RSAT (Remote Server Administration Tools)." $osInfo = Get-CimInstance -ClassName Win32_OperatingSystem if($osInfo.ProductType -eq 1) { # The current platform is a Windows client. Add-WindowsCapability -Name "Rsat.ActiveDirectory.DS-LDS.Tools~~~~0.0.1.0" -Online | Out-Null } else { # The current platform is a Windows server. $feature = Install-WindowsFeature -Name "RSAT-AD-PowerShell" -Confirm:$false if(!$feature.Success) { Throw "Failed to install RSAT-AD-PowerShell Windows feature." } } } Write-Output "Successfully installed the tooling requirements." } } function Get-AksGMSAParameters { <# .SYNOPSIS Get the input parameters for the AKS gMSA setup. #> [CmdletBinding()] Param() BEGIN { Import-Module "Az.Resources" } PROCESS { $params = [Ordered]@{ "aks-cluster-name" = @{ "Prompt" = "AKS cluster name" "Description" = "The name given to the AKS cluster." "ValidationScriptBlock" = { Assert-ValidResourceName $_ } } "aks-cluster-rg-name" = @{ "Prompt" = "AKS cluster resource group name" "Description" = "The Azure resource group where the AKS cluster is deployed." "ValidationScriptBlock" = { Assert-ValidAzureResourceGroupName $_ } } "aks-win-node-pools-names" = @{ "Prompt" = "Comma-separated list with the AKS Windows node pools' names" "Description" = ( "The AKS node pools' names with the Kubernetes Windows agents. " + "These pools are going to be authorized to fetch the configured gMSA.") "Type" = "String[]" "ValidationScriptBlock" = { Assert-ValidAksNodePoolNames $_ } } "domain-dns-server" = @{ "Prompt" = "AD Domain DNS server" "Description" = ( "Active Directory domain DNS server. This is going to be set as the main " + "DNS server for the AKS Windows hosts. The AD DNS server should be able to " + "forward requests, if they are not within the domain, otherwise the Windows " + "hosts' upstream DNS will be broken.") "ValidationScriptBlock" = { [System.Net.IPAddress]$_ } } "domain-fqdn" = @{ "Prompt" = "AD Domain FQDN" "Description" = "The fully qualified domain name for the Active Directory domain." "ValidationScriptBlock" = { Assert-ValidDnsName $_ } } "gmsa-name" = @{ "Prompt" = "gMSA name" "Description" = "The Active Directory group Managed Service Account (gMSA) name." "ValidationScriptBlock" = { Assert-ValidResourceName $_ } } "gmsa-domain-user-name" = @{ "Prompt" = "gMSA domain user name" "Description" = "The name of the domain user authorized to fetch the gMSA." "ValidationScriptBlock" = { Assert-ValidResourceName $_ } } "gmsa-domain-user-password" = @{ "Prompt" = "gMSA domain user password" "Description" = "The password of the domain user authorized to fetch the gMSA." "AsSecureString" = $true } "azure-location" = @{ "Prompt" = "Azure location" "Description" = "The Azure location used for the new resources (Azure key vault and Azure user-assigned managed identity)." "ValidationScriptBlock" = { Write-Host -ForegroundColor Cyan "Validating Azure location" Assert-ValidAzureLocation $_ } } "akv-name" = @{ "Prompt" = "AKV name" "Description" = "The name of the Azure key vault where the gMSA domain user credential will be stored." "ValidationScriptBlock" = { Assert-ValidResourceName $_ } } "akv-secret-name" = @{ "Prompt" = "AKV secret name" "Description" = "The name of the AKV secret that will contain the gMSA domain user credential." "ValidationScriptBlock" = { Assert-ValidResourceName $_ } } "ami-name" = @{ "Prompt" = "Azure MI name" "Description" = "The name of the Azure MI (managed identity) that will be used to fetch the AKV gMSA secret." "ValidationScriptBlock" = { Assert-ValidResourceName $_ } } "gmsa-spec-name" = @{ "Prompt" = "The gMSA spec name" "Description" = "The name given to the Kubernetes gMSA credential spec resource." "ValidationScriptBlock" = { Assert-ValidGMSACredentialSpecName $_ } } "logs-directory" = @{ "Prompt" = "Local logs directory" "Description" = ( "The local directory path where the AKS Windows hosts' logs will be stored. " + "If the directory doesn't exist, it will be created when the logs are collected.`n" + "Defaults to 'C:\gmsa-logs' if empty value is given.") "Default" = "C:\gmsa-logs" "Optional" = $true } "domain-controller-address" = @{ "Prompt" = "AD domain controller address" "Description" = ( "The address of the AD domain controller. This is going to be used " + "for remote commands execution, when creating the gMSA and the domain user. " + "This is not needed, if the AD domain controller doesn't allow remote commands execution. " + "More details in the docs.") "Optional" = $true } "domain-admin-user-name" = @{ "Prompt" = "Domain admin user name" "Description" = ( "The domain admin user name. This is going to be used when creating the gMSA " + "and the gMSA domain user. Give empty value if you don't have domain admin credential. " + "More details in the docs.") "Optional" = $true "ValidationScriptBlock" = { Assert-ValidResourceName $_ } } "domain-admin-user-password" = @{ "Prompt" = "Domain admin user password" "Description" = ( "The domain admin user password. This is going to be used when creating " + "the gMSA and the gMSA domain user. Give empty value if you don't have domain admin credential. " + "More details in the docs.") "AsSecureString" = $true "Optional" = $true } } $inputParams = @{} foreach ($p in $params.GetEnumerator()) { $kwargs = $p.Value $inputParams[$p.Name] = Get-InputParameter @kwargs } return $inputParams } } function Confirm-AksADDCConnectivity { <# .SYNOPSIS Validates that the AKS Windows hosts have proper connectivity to the Active Directory Domain Controller (AD DC) machine. .PARAMETER AksResourceGroupName The AKS cluster resource group name. .PARAMETER AksClusterName The AKS cluster name. .PARAMETER AksWindowsNodePoolsNames The AKS Windows node pools' names. .PARAMETER DomainDnsServer The AD DC DNS server. .PARAMETER RootDomainName The AD DC root domain name. .PARAMETER ContainerImage The container image used for validation. A Windows server image with PowerShell is required. #> [CmdletBinding()] Param( [Parameter(Mandatory=$true)] [ValidateScript({ Assert-ValidAzureResourceGroupName $_ })] [String]$AksResourceGroupName, [Parameter(Mandatory=$true)] [ValidateScript({ Assert-ValidResourceName $_ })] [String]$AksClusterName, [Parameter(Mandatory=$true)] [ValidateScript({ Assert-ValidAksNodePoolNames $_ })] [String[]]$AksWindowsNodePoolsNames, [Parameter(Mandatory=$true)] [ValidateScript({ [System.Net.IPAddress]$_ $true })] [String]$DomainDnsServer, [Parameter(Mandatory=$true)] [ValidateScript({ Assert-ValidDnsName $_ })] [String]$RootDomainName, [String]$ContainerImage="mcr.microsoft.com/windows/servercore:ltsc2019" ) BEGIN { Import-Module "Az.Aks", "Az.Compute" Assert-FoundInPath "kubectl" } PROCESS { Write-Output "Validating connectivity with AD DC for AKS Windows node pools: $($AksWindowsNodePoolsNames -join ', ')." $aksCluster = Get-AzAksCluster -ResourceGroupName $AksResourceGroupName -Name $AksClusterName foreach($nodePoolName in $AksWindowsNodePoolsNames) { $nodePool = Get-AzAksNodePool -ClusterObject $aksCluster -Name $nodePoolName if($nodePool.OsType -ne [Microsoft.Azure.Management.Compute.Models.OperatingSystemTypes]::Windows) { Throw "The agent pool '${nodePoolName}' is not a Windows agent pool." } if($nodePool.AgentPoolType -ne "VirtualMachineScaleSets") { Throw "Unsupported agent pool type. The pool '${nodePoolName}' uses '$($nodePool.AgentPoolType)' type. Only 'VirtualMachineScaleSets' type is supported." } } $manifests = @( @{ "kind" = "ConfigMap" "apiVersion" = "v1" "metadata" = @{ "name" = "aks-addc-validation" "labels" = @{ "app" = "aks-addc-validation" } } "data" = @{ "validate-aks-addc.ps1" = Get-ADDCValidationPSScript -DomainDnsServer $DomainDnsServer -RootDomainName $RootDomainName } }, @{ "kind" = "DaemonSet" "apiVersion" = "apps/v1" "metadata" = @{ "name" = "aks-addc-validation" "labels" = @{ "app" = "aks-addc-validation" } } "spec" = @{ "selector" = @{ "matchLabels" = @{ "app" = "aks-addc-validation" } } "template" = @{ "metadata" = @{ "labels" = @{ "app" = "aks-addc-validation" } } "spec" = @{ "containers" = @( @{ "name" = "aks-addc-validation" "image" = $ContainerImage "command" = @("ping", "-t", "localhost") "volumeMounts" = @( @{ "name" = "aks-addc-validation" "mountPath" = "/aks-addc-validation" } ) } ) "volumes" = @( @{ "name" = "aks-addc-validation" "configMap" = @{ "defaultMode" = 420 "name" = "aks-addc-validation" } } ) "affinity" = @{ "nodeAffinity" = @{ "requiredDuringSchedulingIgnoredDuringExecution" = @{ "nodeSelectorTerms" = @( @{ "matchExpressions" = @( @{ "key" = "agentpool" "operator" = "In" "values" = $AksWindowsNodePoolsNames } ) } ) } } } } } } } ) $jsonManifests = $manifests | ForEach-Object { ConvertTo-Json -Depth 100 $_ } Write-Verbose "Setting up the validation DaemonSet." Install-KubernetesManifests -Manifests $jsonManifests try { Write-Verbose "Waiting until the validation DaemonSet is ready." # Wait until all the DaemonSet pods are ready. It times-out after 5 minutes of waiting. Start-ExecuteWithRetry ` -ScriptBlock ${function:Assert-ReadyDaemonSet} ` -ArgumentList @("aks-addc-validation") ` -RetryMessage "The validation DaemonSet 'aks-addc-validation' is not ready yet." ` -MaxRetryCount 30 -RetryInterval 10 $pods = Invoke-Kubectl "get pods -l app=aks-addc-validation -o json" | ConvertFrom-Json if($pods.items.Length -eq 0) { Throw "There are no AKS AD DC validation pods spawned. Probably something is wrong with the pods' scheduling, or the given AKS Windows nodes are down." } foreach($pod in $pods.items) { Write-Output "Validating Windows host '$($pod.spec.nodeName)'." Invoke-Kubectl "exec $($pod.metadata.name) -- powershell.exe /aks-addc-validation/validate-aks-addc.ps1" } } finally { Write-Verbose "Cleaning up the validation DaemonSet." Uninstall-KubernetesManifests -Manifests $jsonManifests } Write-Output "Successfully validated connectivity with AD DC for AKS Windows node pools: $($AksWindowsNodePoolsNames -join ', ')." } } function Confirm-AksGMSAConfiguration { <# .SYNOPSIS Validates that the AKS cluster has the proper gMSA configuration. .PARAMETER AksResourceGroupName The AKS cluster resource group name. .PARAMETER AksClusterName The AKS cluster name. .PARAMETER AksGMSADomainDnsServer The AKS gMSA DNS server. .PARAMETER AksGMSARootDomainName The AKS gMSA root domain name. #> [CmdletBinding()] Param( [Parameter(Mandatory=$true)] [ValidateScript({ Assert-ValidAzureResourceGroupName $_ })] [String]$AksResourceGroupName, [Parameter(Mandatory=$true)] [ValidateScript({ Assert-ValidResourceName $_ })] [String]$AksClusterName, [Parameter(Mandatory=$true)] [ValidateScript({ [System.Net.IPAddress]$_ $true })] [String]$AksGMSADomainDnsServer, [Parameter(Mandatory=$true)] [ValidateScript({ Assert-ValidDnsName $_ })] [String]$AksGMSARootDomainName ) BEGIN { Assert-FoundInPath "az" } PROCESS { $aksCluster = Invoke-CommandLine "az" "aks show -g $AksResourceGroupName -n $AksClusterName -o json" | ConvertFrom-Json $gmsaProfile = $aksCluster.windowsProfile.gmsaProfile $isEnabled = ($gmsaProfile.enabled -and $gmsaProfile.dnsServer -eq $AksGMSADomainDnsServer -and $gmsaProfile.rootDomainName -eq $AksGMSARootDomainName) if($isEnabled) { Write-Output "The AKS gMSA feature is properly configured." return } if(!$gmsaProfile.enabled) { $promptTitle = "The AKS cluster doesn't have the gMSA feature enabled." $promptQuestion = "Do you want to enable the gMSA feature now?" $yesOption = New-Object System.Management.Automation.Host.ChoiceDescription "&Yes", "Enable AKS gMSA now." $noOption = New-Object System.Management.Automation.Host.ChoiceDescription "&No", "Enable AKS gMSA later." } else { if($gmsaProfile.dnsServer -ne $AksGMSADomainDnsServer) { Write-Warning "The AKS gMSA DNS server is not configured correctly. Current value is '$($gmsaProfile.domainDnsServer)', and it needs to be '$AksGMSADomainDnsServer'." } if($gmsaProfile.rootDomainName -ne $AksGMSARootDomainName) { Write-Warning "The AKS gMSA root domain name is not configured correctly. Current value is '$($gmsaProfile.rootDomainName)', and it needs to be '$AksGMSARootDomainName'." } $promptTitle = "The AKS cluster doesn't have the gMSA feature properly configured." $promptQuestion = "Do you want to reconfigure the gMSA feature now?" $yesOption = New-Object System.Management.Automation.Host.ChoiceDescription "&Yes", "Reconfigure AKS gMSA now." $noOption = New-Object System.Management.Automation.Host.ChoiceDescription "&No", "Reconfigure AKS gMSA later." } $promptChoices = [System.Management.Automation.Host.ChoiceDescription[]]($yesOption, $noOption) $decision = $Host.UI.PromptForChoice($promptTitle, $promptQuestion, $promptChoices, -1) if($decision -eq 0) { Write-Output "Updating AKS gMSA configuration." Invoke-CommandLine "az" "aks update -g $AksResourceGroupName -n $AksClusterName --enable-windows-gmsa --gmsa-dns-server $AksGMSADomainDnsServer --gmsa-root-domain-name $AksGMSARootDomainName -o none" } } } function New-GMSADomainUser { <# .SYNOPSIS Create the gMSA standard domain user. This is going to be authorized to fetch the gMSA credentials. .PARAMETER Name The gMSA standard domain user name. .PARAMETER Password The gMSA standard domain user password. .PARAMETER DomainControllerAddress The AD domain controller address used for remote command execution. This parameter is optional. .PARAMETER DomainAdmin The domain admin used for remote command execution. This parameter is optional. .PARAMETER DomainAdminPassword The domain admin password used for remote command execution. This parameter is optional. #> [CmdletBinding()] Param( [Parameter(Mandatory=$true)] [ValidateScript({ Assert-ValidResourceName $_ })] [String]$Name, [Parameter(Mandatory=$true)] [SecureString]$Password, [String]$DomainControllerAddress, [ValidateScript({ Assert-ValidDomainUserName $_ })] [String]$DomainAdmin, [SecureString]$DomainAdminPassword ) BEGIN { Import-Module "ActiveDirectory" } PROCESS { $remoteParams = Get-ADRemoteParams $DomainControllerAddress $DomainAdmin $DomainAdminPassword $commonParams = @{ "Enabled" = $true "PasswordNeverExpires" = $true "CannotChangePassword" = $true "PassThru" = $true "Confirm" = $false } $adUser = Get-ADUser @remoteParams -Filter "Name -eq '$Name'" if(!$adUser) { Write-Verbose "Creating gMSA domain user." New-ADUser @remoteParams @commonParams -Name $Name -AccountPassword $Password } else { Write-Verbose "gMSA domain user already exists." Write-Verbose "Resetting gMSA domain user password." $adUser | Set-ADAccountPassword @remoteParams -Reset -NewPassword $Password -Confirm:$false Write-Verbose "Updating gMSA domain user info." $adUser | Set-ADUser @remoteParams @commonParams } } } function Remove-GMSADomainUser { <# .SYNOPSIS Remove the gMSA standard domain user. .PARAMETER Name The gMSA standard domain user name. .PARAMETER DomainControllerAddress The AD domain controller address used for remote command execution. This parameter is optional. .PARAMETER DomainAdmin The domain admin used for remote command execution. This parameter is optional. .PARAMETER DomainAdminPassword The domain admin password used for remote command execution. This parameter is optional. #> [CmdletBinding()] Param( [Parameter(Mandatory=$true)] [ValidateScript({ Assert-ValidResourceName $_ })] [String]$Name, [String]$DomainControllerAddress, [ValidateScript({ Assert-ValidDomainUserName $_ })] [String]$DomainAdmin, [SecureString]$DomainAdminPassword ) BEGIN { Import-Module "ActiveDirectory" } PROCESS { $remoteParams = Get-ADRemoteParams $DomainControllerAddress $DomainAdmin $DomainAdminPassword $adUser = Get-ADUser @remoteParams -Filter "Name -eq '$Name'" if(!$adUser) { Write-Output "The AD user doesn't exist." return } Write-Verbose "Removing the AD user." $adUser | Remove-ADUser @remoteParams -Confirm:$false } } function New-GMSA { <# .SYNOPSIS Create the gMSA. .PARAMETER Name The gMSA name. .PARAMETER AuthorizedUser The domain user that will be authorized to fetch the gMSA credentials. .PARAMETER DomainControllerAddress The AD domain controller address used for remote command execution. This parameter is optional. .PARAMETER DomainAdmin The domain admin used for remote command execution. This parameter is optional. .PARAMETER DomainAdminPassword The domain admin password used for remote command execution. This parameter is optional. #> [CmdletBinding()] Param( [Parameter(Mandatory=$true)] [ValidateScript({ Assert-ValidResourceName $_ })] [String]$Name, [Parameter(Mandatory=$true)] [ValidateScript({ Assert-ValidResourceName $_ })] [String]$AuthorizedUser, [String]$DomainControllerAddress, [ValidateScript({ Assert-ValidDomainUserName $_ })] [String]$DomainAdmin, [SecureString]$DomainAdminPassword ) BEGIN { Import-Module "ActiveDirectory" } PROCESS { $remoteParams = Get-ADRemoteParams $DomainControllerAddress $DomainAdmin $DomainAdminPassword $gmsa = Get-ADServiceAccount @remoteParams -Filter "Name -eq '${Name}'" $domain = Get-ADDomain @remoteParams $spn = "host/${Name}", "host/${Name}.$($domain.DNSRoot)" $commonParams = @{ "DNSHostName" = "${Name}.$($domain.DNSRoot)" "PrincipalsAllowedToRetrieveManagedPassword" = $AuthorizedUser "Enabled" = $true "PassThru" = $true "Confirm" = $false } if(!$gmsa) { Write-Verbose "Creating gMSA." New-ADServiceAccount @remoteParams @commonParams -Name $Name -ServicePrincipalNames $spn } else { Write-Verbose "gMSA already exists." Write-Verbose "Updating gMSA info." $gmsa | Set-ADServiceAccount @remoteParams -ServicePrincipalNames $null $gmsa | Set-ADServiceAccount @remoteParams @commonParams -ServicePrincipalNames @{Add = $spn} } } } function Remove-GMSA { <# .SYNOPSIS Remove the gMSA. .PARAMETER Name The gMSA name. .PARAMETER DomainControllerAddress The AD domain controller address used for remote command execution. This parameter is optional. .PARAMETER DomainAdmin The domain admin used for remote command execution. This parameter is optional. .PARAMETER DomainAdminPassword The domain admin password used for remote command execution. This parameter is optional. #> [CmdletBinding()] Param( [Parameter(Mandatory=$true)] [ValidateScript({ Assert-ValidResourceName $_ })] [String]$Name, [String]$DomainControllerAddress, [ValidateScript({ Assert-ValidDomainUserName $_ })] [String]$DomainAdmin, [SecureString]$DomainAdminPassword ) BEGIN { Import-Module "ActiveDirectory" } PROCESS { $remoteParams = Get-ADRemoteParams $DomainControllerAddress $DomainAdmin $DomainAdminPassword $gmsa = Get-ADServiceAccount @remoteParams -Filter "Name -eq '${Name}'" if(!$gmsa) { Write-Output "The gMSA doesn't exist." return } Write-Verbose "Removing the gMSA." $gmsa | Remove-ADServiceAccount @remoteParams -Confirm:$false } } function New-GMSAAzureKeyVault { <# .SYNOPSIS Create the Azure key vault with the gMSA standard domain account credential. .PARAMETER ResourceGroupName The Azure key vault resource group name. .PARAMETER Location The Azure key vault location. .PARAMETER Name The Azure key vault name. .PARAMETER SecretName The name of the Azure key vault secret that holds the credential of the gMSA standard domain user. .PARAMETER GMSADomainUser The gMSA standard domain user name. .PARAMETER GMSADomainUserPassword The gMSA standard domain user password. #> [CmdletBinding()] Param( [Parameter(Mandatory=$true)] [ValidateScript({ Assert-ValidAzureResourceGroupName $_ })] [String]$ResourceGroupName, [Parameter(Mandatory=$true)] [ValidateScript({ Assert-ValidAzureLocation $_ })] [String]$Location, [Parameter(Mandatory=$true)] [ValidateScript({ Assert-ValidResourceName $_ })] [String]$Name, [Parameter(Mandatory=$true)] [ValidateScript({ Assert-ValidResourceName $_ })] [String]$SecretName, [Parameter(Mandatory=$true)] [ValidateScript({ Assert-ValidFQDNDomainUserName $_ })] [String]$GMSADomainUser, [Parameter(Mandatory=$true)] [SecureString]$GMSADomainUserPassword ) BEGIN { Import-Module "Az.KeyVault" } PROCESS { $akv = Get-AzKeyVault -ResourceGroupName $ResourceGroupName -Name $Name -ErrorAction SilentlyContinue if(!$akv) { Write-Verbose "Creating the Azure key vault." $akv = New-AzKeyVault -ResourceGroupName $ResourceGroupName -Location $Location -Name $Name } Write-Verbose "Setting the Azure key vault secret with the gMSA domain user credentials." $secretValue = ConvertTo-SecureString -String "${GMSADomainUser}:$([System.Net.NetworkCredential]::new('', $GMSADomainUserPassword).Password)" -AsPlainText -Force Set-AzKeyVaultSecret -VaultName $Name -Name $SecretName -SecretValue $secretValue | Out-Null return $akv } } function Remove-GMSAAzureKeyVault { <# .SYNOPSIS Remove the Azure key vault with the gMSA standard domain account credential. .PARAMETER ResourceGroupName The Azure key vault resource group name. .PARAMETER Name The Azure key vault name. #> [CmdletBinding()] Param( [Parameter(Mandatory=$true)] [ValidateScript({ Assert-ValidAzureResourceGroupName $_ })] [String]$ResourceGroupName, [Parameter(Mandatory=$true)] [ValidateScript({ Assert-ValidResourceName $_ })] [String]$Name ) BEGIN { Import-Module "Az.KeyVault" } PROCESS { $akv = Get-AzKeyVault -ResourceGroupName $ResourceGroupName -Name $Name -ErrorAction SilentlyContinue if(!$akv) { Write-Output "The Azure key vault doesn't exist." return } Write-Verbose "Removing the Azure key vault." Remove-AzKeyVault -InputObject $akv -Force -Confirm:$false Write-Verbose "Purging the Azure key vault." Remove-AzKeyVault -InputObject $akv -InRemovedState -Force -Confirm:$false } } function New-GMSAManagedIdentity { <# .SYNOPSIS Create the user-assigned managed identity. This is going to be authorized to fetch the Azure key vault secret, that holds the gMSA standard domain user credential. .PARAMETER ResourceGroupName The user-assigned managed identity resource group name. .PARAMETER Location The user-assigned managed identity location. .PARAMETER Name The user-assigned managed identity name. #> [CmdletBinding()] Param( [Parameter(Mandatory=$true)] [ValidateScript({ Assert-ValidAzureResourceGroupName $_ })] [String]$ResourceGroupName, [Parameter(Mandatory=$true)] [ValidateScript({ Assert-ValidAzureLocation $_ })] [String]$Location, [Parameter(Mandatory=$true)] [ValidateScript({ Assert-ValidResourceName $_ })] [String]$Name ) BEGIN { Import-Module "Az.ManagedServiceIdentity" } PROCESS { $mi = Get-AzUserAssignedIdentity -ResourceGroupName $ResourceGroupName -Name $Name -ErrorAction SilentlyContinue if(!$mi) { Write-Verbose "Creating the gMSA user-assigned managed identity." $mi = New-AzUserAssignedIdentity -ResourceGroupName $ResourceGroupName -Location $Location -Name $Name } return $mi } } function Remove-GMSAManagedIdentity { <# .SYNOPSIS Remove the user-assigned managed identity, used to fetch the gMSA standard domain user credential from AKV. .PARAMETER ResourceGroupName The user-assigned managed identity resource group name. .PARAMETER Name The user-assigned managed identity name. #> [CmdletBinding()] Param( [Parameter(Mandatory=$true)] [ValidateScript({ Assert-ValidAzureResourceGroupName $_ })] [String]$ResourceGroupName, [Parameter(Mandatory=$true)] [ValidateScript({ Assert-ValidResourceName $_ })] [String]$Name ) BEGIN { Import-Module "Az.ManagedServiceIdentity" } PROCESS { $mi = Get-AzUserAssignedIdentity -ResourceGroupName $ResourceGroupName -Name $Name -ErrorAction SilentlyContinue if(!$mi) { Write-Output "The user-assigned managed identity doesn't exist." return } Write-Verbose "Removing the user-assigned managed identity." Remove-AzUserAssignedIdentity -InputObject $mi -Confirm:$false } } function Grant-AkvAccessToAksWindowsHosts { <# .SYNOPSIS This function will: * Append the user-assigned managed identity to the AKS Windows agent pools. * Configure the Azure key vault read access policy for the user-assigned managed identity. .PARAMETER AksResourceGroupName The AKS cluster resource group name. .PARAMETER AksClusterName The AKS cluster name. .PARAMETER AksWindowsNodePoolsNames The AKS Windows node pools' names. .PARAMETER VaultResourceGroupName The Azure key vault resource group name. .PARAMETER VaultName The Azure key vault name. .PARAMETER ManagedIdentityResourceGroupName The user-assigned managed identity resource group name. .PARAMETER ManagedIdentityName The user-assigned managed identity name. #> [CmdletBinding()] Param( [Parameter(Mandatory=$true)] [ValidateScript({ Assert-ValidAzureResourceGroupName $_ })] [String]$AksResourceGroupName, [Parameter(Mandatory=$true)] [ValidateScript({ Assert-ValidResourceName $_ })] [String]$AksClusterName, [Parameter(Mandatory=$true)] [ValidateScript({ Assert-ValidAksNodePoolNames $_ })] [String[]]$AksWindowsNodePoolsNames, [Parameter(Mandatory=$true)] [ValidateScript({ Assert-ValidAzureResourceGroupName $_ })] [String]$VaultResourceGroupName, [Parameter(Mandatory=$true)] [ValidateScript({ Assert-ValidResourceName $_ })] [String]$VaultName, [Parameter(Mandatory=$true)] [ValidateScript({ Assert-ValidAzureResourceGroupName $_ })] [String]$ManagedIdentityResourceGroupName, [Parameter(Mandatory=$true)] [ValidateScript({ Assert-ValidResourceName $_ })] [String]$ManagedIdentityName ) BEGIN { Import-Module "Az.ManagedServiceIdentity", "Az.KeyVault", "Az.Aks", "Az.Compute" } PROCESS { Write-Output "Granting Azure key vault access to the user-assigned managed identity." $mi = Get-AzUserAssignedIdentity -ResourceGroupName $ManagedIdentityResourceGroupName -Name $ManagedIdentityName $akv = Get-AzKeyVault -ResourceGroupName $VaultResourceGroupName -Name $VaultName if(!$akv) { Throw "The Azure key vault '${VaultName}' was not found under resource group '${VaultResourceGroupName}'." } $aksCluster = Get-AzAksCluster -ResourceGroupName $AksResourceGroupName -Name $AksClusterName foreach($nodePoolName in $AksWindowsNodePoolsNames) { $nodePool = Get-AzAksNodePool -ClusterObject $aksCluster -Name $nodePoolName if($nodePool.OsType -ne [Microsoft.Azure.Management.Compute.Models.OperatingSystemTypes]::Windows) { Throw "The agent pool '${nodePoolName}' is not a Windows agent pool." } if($nodePool.AgentPoolType -ne "VirtualMachineScaleSets") { Throw "Unsupported agent pool type. The pool '${nodePoolName}' uses '$($nodePool.AgentPoolType)' type. Only 'VirtualMachineScaleSets' type is supported." } } # Assign the managed identity to the Windows agent pools. $windowsVMSSList = Get-AzVmss -ResourceGroupName $aksCluster.NodeResourceGroup | Where-Object { $_.Tags["aks-managed-poolName"] -in $AksWindowsNodePoolsNames } if(!$windowsVMSSList) { Throw "No VMSS resources, for the Windows agent pools, were found under resource group '$($aksCluster.NodeResourceGroup)'." } $validIdentityTypes = @( [Microsoft.Azure.Management.Compute.Models.ResourceIdentityType]::UserAssigned, [Microsoft.Azure.Management.Compute.Models.ResourceIdentityType]::SystemAssignedUserAssigned ) foreach($vmss in $windowsVMSSList) { if($vmss.Identity.Type -notin $validIdentityTypes) { Throw "The VMSS '$($vmss.Name)' doesn't allow user-assigned managed identities. The current VMSS identity type is '$($vmss.Identity.Type)'." } $exists = $vmss.Identity.UserAssignedIdentities.Keys | Where-Object { $_ -eq $mi.Id } if($exists) { Write-Verbose "The user-assigned managed identity is already assigned to the Windows VMSS '$($vmss.Name)'." continue } $identityIDs = New-Object Collections.Generic.List[String] $vmss.Identity.UserAssignedIdentities.Keys | ForEach-Object { $identityIDs.Add($_) } $identityIDs.Add($mi.Id) Write-Verbose "Adding the user-assigned managed identity to the Windows VMSS '$($vmss.Name)'." Update-AzVmss ` -ResourceGroupName $aksCluster.NodeResourceGroup ` -Name $vmss.Name ` -IdentityType $vmss.Identity.Type ` -IdentityId $identityIDs | Out-Null } # Set the proper AKV access policies for the managed identity. Write-Verbose "Setting read Azure key vault access policy for the user-assigned managed identity." Start-ExecuteWithRetry ` -ScriptBlock { Param( [Parameter(Mandatory=$true)] [String]$VaultResourceGroupName, [Parameter(Mandatory=$true)] [String]$VaultName, [Parameter(Mandatory=$true)] [String]$ObjectID ) Set-AzKeyVaultAccessPolicy ` -ResourceGroupName $VaultResourceGroupName ` -VaultName $VaultName ` -ObjectId $ObjectID ` -PermissionsToSecrets "get" } ` -ArgumentList @($VaultResourceGroupName, $VaultName, $mi.PrincipalId) ` -MaxRetryCount 15 -RetryInterval 10 ` -RetryMessage "Failed to set Azure key vault access policy. Perhaps the managed identity is not yet visible to the Azure key vault. Retrying." Write-Output "Successfully granted access to the user-assigned managed identity." } } function Get-AksAgentPoolsAkvAccess { <# .SYNOPSIS This function will output the AKS agent pools' access to all the Azure key vaults found in the given resource groups. .PARAMETER AksResourceGroupName The AKS cluster resource group name. .PARAMETER AksClusterName The AKS cluster name. .PARAMETER VaultResourceGroupNames The resource group names where the Azure key vaults are found. #> [CmdletBinding()] Param( [Parameter(Mandatory=$true)] [ValidateScript({ Assert-ValidAzureResourceGroupName $_ })] [String]$AksResourceGroupName, [Parameter(Mandatory=$true)] [ValidateScript({ Assert-ValidResourceName $_ })] [String]$AksClusterName, [Parameter(Mandatory=$true)] [ValidateScript({ foreach($name in $_) { Assert-ValidAzureResourceGroupName $name | Out-Null } $true })] [String[]]$VaultResourceGroupNames ) BEGIN { Import-Module "Az.KeyVault", "Az.Aks", "Az.Compute" $env:SuppressAzurePowerShellBreakingChangeWarnings = $true } PROCESS { Write-Verbose "Getting all the Azure key vaults." $vaults = New-Object Collections.Generic.List[Microsoft.Azure.Commands.KeyVault.Models.PSKeyVault] foreach($rgName in $VaultResourceGroupNames) { Get-AzKeyVault -ResourceGroupName $rgName | ForEach-Object { $vaults.Add((Get-AzKeyVault -ResourceGroupName $_.ResourceGroupName -Name $_.VaultName)) } } if(!$vaults) { Throw "No Azure key vaults were found under resource groups '${VaultResourceGroupNames}'." } Write-Verbose "Checking if there are agent pools in the AKS cluster." $aksCluster = Get-AzAksCluster -ResourceGroupName $AksResourceGroupName -Name $AksClusterName if(!$aksCluster.AgentPoolProfiles) { Throw "No agent pools were found in the AKS cluster '${AksClusterName}'." } Write-Verbose "Checking AKS agent pools' secret accesses for all the Azure key vaults." $table = New-Object System.Data.DataTable $table.Columns.AddRange(@("AksAgentPool", "AzureKeyVault", "IsAuthorized")) foreach($pool in $aksCluster.AgentPoolProfiles) { $isAuthorized = $true foreach($vault in $vaults) { $vmss = Get-AzVmss -ResourceGroupName $aksCluster.NodeResourceGroup | Where-Object { $_.Tags["aks-managed-poolName"] -eq $pool.Name } $vaultPrincipals = $vault.AccessPolicies | Where-Object { ($_.PermissionsToSecrets -contains 'get') } $principalsWithAccess = $vmss.Identity.UserAssignedIdentities.Values.PrincipalId | Where-Object { $_ -in $vaultPrincipals.ObjectId } if(!$principalsWithAccess) { $isAuthorized = $false } $table.Rows.Add($pool.Name, $vault.VaultName, $isAuthorized) | Out-Null } } return $table } END { $env:SuppressAzurePowerShellBreakingChangeWarnings = $null } } function New-GMSACredentialSpec { <# .SYNOPSIS Create a new gMSA credential spec Kubernetes resource. .PARAMETER Name The spec name. .PARAMETER GMSAName The gMSA name. .PARAMETER ManagedIdentityResourceGroupName The gMSA managed identity resource group name. .PARAMETER ManagedIdentityName The gMSA managed identity name. .PARAMETER VaultName The gMSA Azure key vault name. .PARAMETER VaultGMSASecretName The gMSA Azure key vault secret name. .PARAMETER DomainControllerAddress The AD domain controller address used for remote command execution. This parameter is optional. .PARAMETER DomainUser The domain user used for remote command execution. This parameter is optional. .PARAMETER DomainUserPassword The domain user password used for remote command execution. This parameter is optional. #> [CmdletBinding()] Param( [Parameter(Mandatory=$true)] [ValidateScript({ Assert-ValidGMSACredentialSpecName $_ })] [String]$Name, [Parameter(Mandatory=$true)] [ValidateScript({ Assert-ValidResourceName $_ })] [String]$GMSAName, [Parameter(Mandatory=$true)] [ValidateScript({ Assert-ValidAzureResourceGroupName $_ })] [String]$ManagedIdentityResourceGroupName, [Parameter(Mandatory=$true)] [ValidateScript({ Assert-ValidResourceName $_ })] [String]$ManagedIdentityName, [Parameter(Mandatory=$true)] [ValidateScript({ Assert-ValidResourceName $_ })] [String]$VaultName, [Parameter(Mandatory=$true)] [ValidateScript({ Assert-ValidResourceName $_ })] [String]$VaultGMSASecretName, [String]$DomainControllerAddress, [ValidateScript({ Assert-ValidDomainUserName $_ })] [String]$DomainUser, [SecureString]$DomainUserPassword ) BEGIN { Assert-FoundInPath "kubectl" } PROCESS { Write-Output "Creating gMSA credential spec." # Setup the GMSA credential spec. $manifest = Get-GMSACredentialSpecManifest ` -Name $Name ` -GMSAName $GMSAName ` -ManagedIdentityResourceGroupName $ManagedIdentityResourceGroupName ` -ManagedIdentityName $ManagedIdentityName ` -VaultName $VaultName ` -VaultGMSASecretName $VaultGMSASecretName ` -DomainControllerAddress $DomainControllerAddress ` -DomainUser $DomainUser ` -DomainUserPassword $DomainUserPassword Install-KubernetesManifests -Manifests (ConvertTo-Json -InputObject $manifest -Depth 100) # Setup the GMSACredentialSpec RBAC ClusterRole. Install-KubernetesManifests -Manifests (Get-GMSACredentialSpecClusterRoleManifest -SpecName $Name | ConvertTo-Json -Depth 100) # Setup the GMSACredentialSpec RBAC RoleBinding. Install-KubernetesManifests -Manifests (Get-GMSACredentialSpecRoleBindingManifest -SpecName $Name | ConvertTo-Json -Depth 100) } } function Remove-GMSACredentialSpec { <# .SYNOPSIS Remove a gMSA credential spec Kubernetes resource. .PARAMETER Name The spec name. #> [CmdletBinding()] Param( [Parameter(Mandatory=$true)] [ValidateScript({ Assert-ValidGMSACredentialSpecName $_ })] [String]$Name ) BEGIN { Assert-FoundInPath "kubectl" } PROCESS { Write-Verbose "Deleting the gMSA credential spec." Invoke-Kubectl "delete gmsacredentialspec --ignore-not-found=true $Name" Write-Verbose "Deleting the gMSA credential spec RBAC ClusterRole." Uninstall-KubernetesManifests -Manifests (Get-GMSACredentialSpecClusterRoleManifest -SpecName $Name | ConvertTo-Json -Depth 100) Write-Verbose "Deleting the gMSA credential spec RBAC RoleBinding." Uninstall-KubernetesManifests -Manifests (Get-GMSACredentialSpecRoleBindingManifest -SpecName $Name | ConvertTo-Json -Depth 100) } } function Get-GMSASampleApplicationYAML { <# .SYNOPSIS Get a sample YAML manifest with a Kubernetes deployment using gMSA credential spec. .PARAMETER SpecName The gMSA credential spec name. .PARAMETER AksWindowsNodePoolsNames The AKS Windows node pools' names. .PARAMETER Name The application name. .PARAMETER Namespace The application namespace. .PARAMETER Labels Hashtable with the application labels. #> [CmdletBinding()] Param( [Parameter(Mandatory=$true)] [ValidateScript({ Assert-ValidGMSACredentialSpecName $_ })] [String]$SpecName, [Parameter(Mandatory=$true)] [ValidateScript({ Assert-ValidAksNodePoolNames $_ })] [String[]]$AksWindowsNodePoolsNames, [ValidateScript({ Assert-ValidResourceName $_ })] [String]$Name, [ValidateScript({ Assert-ValidResourceName $_ })] [String]$Namespace="default", [Hashtable]$Labels=@{} ) PROCESS { if(!$Name) { $Name = "${SpecName}-demo" } if(!$Labels["app"]) { $Labels["app"] = $Name } $labelsStr = New-Object Collections.Generic.List[String] foreach($item in $Labels.GetEnumerator()) { $labelsStr.Add("$($item.Name): $($item.Value)") } return @" --- kind: ConfigMap apiVersion: v1 metadata: labels: $($labelsStr -join "`n ") name: ${Name} namespace: ${Namespace} data: run.ps1: | `$ErrorActionPreference = "Stop" Write-Output "Configuring IIS with authentication." # Add required Windows features, since they are not installed by default. Install-WindowsFeature "Web-Windows-Auth", "Web-Asp-Net45" # Create simple ASP.Net page. New-Item -Force -ItemType Directory -Path 'C:\inetpub\wwwroot\app' Set-Content -Path 'C:\inetpub\wwwroot\app\default.aspx' -Value 'Authenticated as <B><%=User.Identity.Name%></B>, Type of Authentication: <B><%=User.Identity.AuthenticationType%></B>' # Configure IIS with authentication. Import-Module IISAdministration Start-IISCommitDelay (Get-IISConfigSection -SectionPath 'system.webServer/security/authentication/windowsAuthentication').Attributes['enabled'].value = `$true (Get-IISConfigSection -SectionPath 'system.webServer/security/authentication/anonymousAuthentication').Attributes['enabled'].value = `$false (Get-IISServerManager).Sites[0].Applications[0].VirtualDirectories[0].PhysicalPath = 'C:\inetpub\wwwroot\app' Stop-IISCommitDelay Write-Output "IIS with authentication is ready." C:\ServiceMonitor.exe w3svc --- apiVersion: apps/v1 kind: Deployment metadata: labels: $($labelsStr -join "`n ") name: ${Name} namespace: ${Namespace} spec: replicas: 1 selector: matchLabels: $($labelsStr -join "`n ") template: metadata: labels: $($labelsStr -join "`n ") spec: securityContext: windowsOptions: gmsaCredentialSpecName: ${SpecName} containers: - name: iis image: mcr.microsoft.com/windows/servercore/iis:windowsservercore-ltsc2019 imagePullPolicy: IfNotPresent command: - powershell args: - -File - /gmsa-demo/run.ps1 volumeMounts: - name: gmsa-demo mountPath: /gmsa-demo volumes: - configMap: defaultMode: 420 name: ${Name} name: gmsa-demo affinity: nodeAffinity: requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: - matchExpressions: - key: agentpool operator: In values: - $($AksWindowsNodePoolsNames -join "`n - ") --- apiVersion: v1 kind: Service metadata: labels: $($labelsStr -join "`n ") name: ${Name} namespace: ${Namespace} spec: ports: - port: 80 targetPort: 80 selector: $($labelsStr -join "`n ") type: LoadBalancer "@ } } function Start-GMSACredentialSpecValidation { <# .SYNOPSIS Validate that the gMSA credential spec is functional. A pod using gMSA credential spec is started on each Windows host for validation purposes. .PARAMETER SpecName The gMSA credential spec name. .PARAMETER AksWindowsNodePoolsNames The AKS Windows node pools' names. .PARAMETER ContainerImage The container image used for validation. A Windows server core image with PowerShell is required. #> [CmdletBinding()] Param( [Parameter(Mandatory=$true)] [ValidateScript({ Assert-ValidGMSACredentialSpecName $_ })] [String]$SpecName, [Parameter(Mandatory=$true)] [ValidateScript({ Assert-ValidAksNodePoolNames $_ })] [String[]]$AksWindowsNodePoolsNames, [String]$ContainerImage="mcr.microsoft.com/windows/servercore:ltsc2019" ) BEGIN { Assert-FoundInPath "kubectl" } PROCESS { Write-Output "Validating gMSA credential spec '${SpecName}'." Write-Verbose "Getting the gMSA credential spec details." $spec = Invoke-Kubectl "get gmsacredentialspec $SpecName -o json" | ConvertFrom-Json $domainName = $spec.credspec.DomainJoinConfig.DnsName $manifests = @( @{ "kind" = "ConfigMap" "apiVersion" = "v1" "metadata" = @{ "name" = "gmsa-spec-${SpecName}-validation" "labels" = @{ "app" = "gmsa-spec-${SpecName}-validation" } } "data" = @{ "validate-spec.ps1" = Get-GMSACredSpecValidationPSScript $domainName } }, @{ "kind" = "DaemonSet" "apiVersion" = "apps/v1" "metadata" = @{ "name" = "gmsa-spec-${SpecName}-validation" "labels" = @{ "app" = "gmsa-spec-${SpecName}-validation" } } "spec" = @{ "selector" = @{ "matchLabels" = @{ "app" = "gmsa-spec-${SpecName}-validation" } } "template" = @{ "metadata" = @{ "labels" = @{ "app" = "gmsa-spec-${SpecName}-validation" } } "spec" = @{ "securityContext" = @{ "windowsOptions" = @{ "gmsaCredentialSpecName" = $SpecName } } "containers" = @( @{ "name" = "gmsa-spec-${SpecName}-validation" "image" = $ContainerImage "command" = @("ping", "-t", "localhost") "volumeMounts" = @( @{ "name" = "gmsa-validation" "mountPath" = "/gmsa-validation" } ) } ) "volumes" = @( @{ "name" = "gmsa-validation" "configMap" = @{ "defaultMode" = 420 "name" = "gmsa-spec-${SpecName}-validation" } } ) "affinity" = @{ "nodeAffinity" = @{ "requiredDuringSchedulingIgnoredDuringExecution" = @{ "nodeSelectorTerms" = @( @{ "matchExpressions" = @( @{ "key" = "agentpool" "operator" = "In" "values" = $AksWindowsNodePoolsNames } ) } ) } } } } } } } ) $jsonManifests = $manifests | ForEach-Object { ConvertTo-Json -Depth 100 $_ } Write-Verbose "Setting up the validation DaemonSet." Install-KubernetesManifests -Manifests $jsonManifests try { Write-Verbose "Waiting until the validation DaemonSet is ready." # Wait until all the DaemonSet pods are ready. It times-out after 5 minutes of waiting. Start-ExecuteWithRetry ` -ScriptBlock ${function:Assert-ReadyDaemonSet} ` -ArgumentList @("gmsa-spec-${SpecName}-validation") ` -RetryMessage "The validation DaemonSet 'gmsa-spec-${SpecName}-validation' is not ready yet." ` -MaxRetryCount 30 -RetryInterval 10 $pods = Invoke-Kubectl "get pods -l app=gmsa-spec-${SpecName}-validation -o json" | ConvertFrom-Json if($pods.items.Length -eq 0) { Throw "There are no validation pods spawned. Probably something is wrong with the pods' scheduling or the gMSA admission webhook." } foreach($pod in $pods.items) { Write-Output "Validating Windows host '$($pod.spec.nodeName)'." Invoke-Kubectl "exec $($pod.metadata.name) -- powershell.exe /gmsa-validation/validate-spec.ps1" } } finally { Write-Verbose "Cleaning up the validation DaemonSet." Uninstall-KubernetesManifests -Manifests $jsonManifests } Write-Output "Successfully validated the gMSA credential spec '${SpecName}'." } } function Copy-WindowsHostsLogs { <# .SYNOPSIS Extract logs from each Windows host. .PARAMETER LogsDirectory The local directory where the Windows hosts' logs are copied. .PARAMETER WindowsHosts List of the Kubernetes Windows nodes to collect logs from. If this is not explicitly given, the logs are collected from all the Windows hosts. .PARAMETER ContainerImage The container image used for logs collection. A Windows server core image with PowerShell is required. #> [CmdletBinding()] Param( [Parameter(Mandatory=$true)] [String]$LogsDirectory, [ValidateScript({ foreach($name in $_) { Assert-ValidResourceName $name | Out-Null } $true })] [String[]]$WindowsHosts=@(), [String]$ContainerImage="mcr.microsoft.com/windows/servercore:ltsc2019" ) BEGIN { Assert-FoundInPath "kubectl" } PROCESS { Write-Output "Extracting the Windows hosts logs to local directory '${LogsDirectory}'." Write-Verbose "Creating local logs directory '${LogsDirectory}'." New-Item -ItemType Directory -Path $LogsDirectory -Force | Out-Null $name = "windows-logs-$(Get-UniqueId)" $manifest = @{ "kind" = "DaemonSet" "apiVersion" = "apps/v1" "metadata" = @{ "name" = $name "labels" = @{ "app" = $name } } "spec" = @{ "selector" = @{ "matchLabels" = @{ "app" = $name } } "template" = @{ "metadata" = @{ "labels" = @{ "app" = $name } } "spec" = @{ "containers" = @( @{ "name" = $name "image" = $ContainerImage "command" = @("ping", "-t", "localhost") "volumeMounts" = @( @{ "name" = "host" "mountPath" = "/host" } ) } ) "volumes" = @( @{ "name" = "host" "hostPath" = @{ "path" = "/" } } ) "nodeSelector" = @{ "kubernetes.io/os" = "windows" } } } } } $jsonManifest = ConvertTo-Json -Depth 100 $manifest Write-Verbose "Setting up the logs collection DaemonSet." Install-KubernetesManifests -Manifests $jsonManifest $tmpDirPath = "/tmp-logs-$(Get-UniqueId)" try { Write-Verbose "Creating temporary logs directory." New-Item -ItemType Directory -Path $tmpDirPath | Out-Null Write-Verbose "Waiting until the logs collection DaemonSet is ready." Start-ExecuteWithRetry ` -ScriptBlock ${function:Assert-ReadyDaemonSet} ` -ArgumentList @($name) ` -RetryMessage "The logs collection DaemonSet '${name}' is not ready yet." ` -MaxRetryCount 30 -RetryInterval 10 $logsFiles = @( "/k/kubelet.log", "/k/kubelet.err.log", "/windows/system32/winevt/Logs/Microsoft-AKSGMSAPlugin%4Admin.evtx", "/windows/system32/winevt/Logs/Microsoft-Windows-Containers-CCG%4Admin.evtx" ) $pods = Invoke-Kubectl "get pods -l app=${name} -o json" | ConvertFrom-Json foreach($pod in $pods.items) { $podName = $pod.metadata.name $nodeName = $pod.spec.nodeName if($WindowsHosts.Length -gt 0 -and $nodeName -notin $WindowsHosts) { continue } Write-Verbose "Creating Windows host logs directory '${LogsDirectory}/${nodeName}'." New-Item -ItemType Directory -Path "${LogsDirectory}/${nodeName}" -Force | Out-Null Write-Output "Extracting Windows host '${nodeName}' logs." foreach($logFile in $logsFiles) { Write-Verbose "Extracting '${logFile}'." $fileName = Split-Path $logFile -Leaf Invoke-Kubectl "exec ${podName} -- powershell.exe cp /host/${logFile} /" Invoke-Kubectl "cp ${podName}:${fileName} ${tmpDirPath}/${fileName}" Move-Item -Force -Path "${tmpDirPath}/${fileName}" -Destination "${LogsDirectory}/${nodeName}" } } } finally { Write-Verbose "Cleaning up the temporary logs directory." Remove-Item -Recurse -Force -Path $tmpDirPath -ErrorAction SilentlyContinue Write-Verbose "Cleaning up the logs collection DaemonSet." Uninstall-KubernetesManifests -Manifests $jsonManifest } Write-Output "Finished copying logs from all the Windows hosts." } } ### Private functions below function Assert-FoundInPath { <# .SYNOPSIS Assert that a binary is found in PATH. Useful to verify that the requirements are installed. .PARAMETER Name The binary name(s). #> Param( [Parameter(Mandatory=$true)] [String[]]$Name ) foreach($binName in $Name) { $bin = Get-Command -Name $binName -ErrorAction SilentlyContinue if(!$bin) { Throw "The binary '${binName}' was not found in PATH. Please run 'Install-ToolingRequirements' first." } } } function Get-ADRemoteParams { <# .SYNOPSIS Get the parameters used for remote execution by the functions from the ActiveDirectory RSAT PowerShell module. .PARAMETER DomainControllerAddress The AD domain controller address. .PARAMETER DomainUser The domain user. If this is set, the parameter 'DomainUserPassword' is mandatory. .PARAMETER DomainUserPassword The domain user password. If this is set, the parameter 'DomainUser' is mandatory. #> Param( [String]$DomainControllerAddress, [ValidateScript({ if($_.Split('\').Count -ne 2) { Throw "The domain user must be given as 'DOMAIN\USERNAME'." } $true })] [String]$DomainUser, [SecureString]$DomainUserPassword ) $params = @{} if($DomainControllerAddress) { $params["Server"] = $DomainControllerAddress } if($DomainUser -and !$DomainUserPassword) { Throw "The domain user parameter is specified, but the password parameter is missing." } if(!$DomainUser -and $DomainUserPassword) { Throw "The domain user parameter is missing, but the password parameter is specified." } if($DomainUser -and $DomainUserPassword) { $params["Credential"] = New-Object PSCredential($DomainUser, $DomainUserPassword) } return $params } function Get-GMSACredentialSpecManifest { <# .SYNOPSIS Get the gMSA credential spec Kubernetes manifest. .PARAMETER Name The spec name. .PARAMETER GMSAName The gMSA name. .PARAMETER ManagedIdentityResourceGroupName The managed identity resource group name. .PARAMETER ManagedIdentityName The managed identity name. .PARAMETER VaultName The Azure key vault name. .PARAMETER VaultGMSASecretName The gMSA Azure key vault secret name. .PARAMETER DomainControllerAddress The AD domain controller address used for remote command execution. This parameter is optional. .PARAMETER DomainUser The domain user used for remote command execution. This parameter is optional. .PARAMETER DomainUserPassword The domain user password used for remote command execution. This parameter is optional. #> Param( [Parameter(Mandatory=$true)] [String]$Name, [Parameter(Mandatory=$true)] [String]$GMSAName, [Parameter(Mandatory=$true)] [String]$ManagedIdentityResourceGroupName, [Parameter(Mandatory=$true)] [String]$ManagedIdentityName, [Parameter(Mandatory=$true)] [String]$VaultName, [Parameter(Mandatory=$true)] [String]$VaultGMSASecretName, [String]$DomainControllerAddress, [ValidateScript({ if($_.Split('\').Count -ne 2) { Throw "The domain user must be given as 'DOMAIN\USERNAME'." } $true })] [String]$DomainUser, [SecureString]$DomainUserPassword ) Write-Verbose "Getting the user-assigned managed identity info." $mi = Get-AzUserAssignedIdentity -ResourceGroupName $ManagedIdentityResourceGroupName -Name $ManagedIdentityName $remoteParams = Get-ADRemoteParams $DomainControllerAddress $DomainUser $DomainUserPassword Write-Verbose "Getting the AD domain info." $domain = Get-ADDomain @remoteParams Write-Verbose "Getting the AKV gMSA secret info." $secret = Get-AzKeyVaultSecret -VaultName $VaultName -Name $VaultGMSASecretName if(!$secret) { Throw "The secret '${VaultGMSASecretName}' was not found in the Azure key vault '${VaultName}'." } $secretUri = ([uri]$secret.Id).AbsoluteUri -replace "/$($secret.Version)$", '' return @{ "apiVersion" = "windows.k8s.io/v1alpha1" "kind" = "GMSACredentialSpec" "metadata" = @{ "name" = $Name } "credspec" = @{ "CmsPlugins" = @( "ActiveDirectory" ) "DomainJoinConfig" = @{ "Sid" = $domain.DomainSID.Value "MachineAccountName" = $GMSAName "Guid" = $domain.ObjectGUID.Guid "DnsTreeName" = $domain.Forest "DnsName" = $domain.DNSRoot "NetBiosName" = $domain.NetBIOSName } "ActiveDirectoryConfig" = @{ "GroupManagedServiceAccounts" = @( @{ "Name" = $GMSAName "Scope" = $domain.DNSRoot }, @{ "Name" = $GMSAName "Scope" = $domain.NetBIOSName } ) "HostAccountConfig" = @{ "PortableCcgVersion" = "1" "PluginGUID" = "{CCC2A336-D7F3-4818-A213-272B7924213E}" "PluginInput" = "ObjectId=$($mi.PrincipalId);SecretUri=${secretUri}" } } } } } function Get-GMSACredentialSpecClusterRoleManifest { <# .SYNOPSIS Get the RBAC ClusterRole Kubernetes manifest for the gMSA credential spec. .PARAMETER SpecName The spec name. #> Param( [Parameter(Mandatory=$true)] [String]$SpecName ) return @{ "apiVersion" = "rbac.authorization.k8s.io/v1" "kind" = "ClusterRole" "metadata" = @{ "name" = "${SpecName}-role" } "rules" = @( @{ "apiGroups" = @("windows.k8s.io") "resources" = @("gmsacredentialspecs") "verbs" = @("use") "resourceNames" = @($SpecName) } ) } } function Get-GMSACredentialSpecRoleBindingManifest { <# .SYNOPSIS Get the RBAC RoleBinding Kubernetes manifest for the gMSA credential spec. .PARAMETER SpecName The spec name. #> Param( [Parameter(Mandatory=$true)] [String]$SpecName ) return @{ "apiVersion" = "rbac.authorization.k8s.io/v1" "kind" = "RoleBinding" "metadata" = @{ "name" = "allow-default-svc-account-read-on-${SpecName}" "namespace" = "default" } "subjects" = @( @{ "kind" = "ServiceAccount" "name" = "default" "namespace" = "default" } ) "roleRef" = @{ "kind" = "ClusterRole" "name" = "${SpecName}-role" "apiGroup" = "rbac.authorization.k8s.io" } } } function Get-ADDCValidationPSScript { <# .SYNOPSIS Get the PowerShell script used to validate the connectivity to the Active Directory Domain Controller (AD DC) machine. .PARAMETER DomainDnsServer The AD DC DNS server. .PARAMETER RootDomainName The AD DC root domain name. #> Param( [Parameter(Mandatory=$true)] [String]$DomainDnsServer, [Parameter(Mandatory=$true)] [String]$RootDomainName ) return @" `$ErrorActionPreference = "Stop" # Checks that the root domain name can be resolved. Resolve-DnsName -Name $RootDomainName -Server $DomainDnsServer | Format-List * # Queries the Domain Name System (DNS) server for a list of domain controllers # and their corresponding IP addresses. nltest /dsgetdc:${RootDomainName} /server:${DomainDnsServer} if(`$LASTEXITCODE) { Throw "Validation command 'nltest /dsgetdc:${RootDomainName} /server:${DomainDnsServer}' had exit code `${LASTEXITCODE}." } "@ } function Get-GMSACredSpecValidationPSScript { <# .SYNOPSIS Get the PowerShell script used to validate the gMSA credential spec. .PARAMETER DomainName The Active Directory domain name. #> Param( [Parameter(Mandatory=$true)] [String]$DomainName ) return @" `$ErrorActionPreference = "Stop" # Returns the name of the parent domain of the server. nltest /parentdomain if(`$LASTEXITCODE) { Throw "Validation command 'nltest /parentdomain' had exit code `${LASTEXITCODE}." } # Checks that the configured DNS server can resolved the domain name. Resolve-DnsName -Name $DomainName # Queries the Domain Name System (DNS) server for a list of domain controllers and # their corresponding IP addresses. nltest /dsgetdc:${DomainName} if(`$LASTEXITCODE) { Throw "Validation command 'nltest /dsgetdc:${DomainName}' had exit code `${LASTEXITCODE}." } # Checks the status of the secure channel that the NetLogon service established. `$result = nltest /sc_verify:${DomainName} `$result if(`$LASTEXITCODE) { Throw "Validation command 'nltest /sc_verify:${DomainName}' had exit code `${LASTEXITCODE}." } `$successDCConnMsg = 'Trusted DC Connection Status Status = 0 0x0 NERR_Success' `$successVerificationStatusMsg = 'Trust Verification Status = 0 0x0 NERR_Success' if(!`$result.Contains(`$successDCConnMsg) -or !`$result.Contains(`$successVerificationStatusMsg)) { Throw "Validation command 'nltest /sc_verify:${DomainName}' reported errors." } # Validate if we can get a kerberos ticket granting ticket (TGT), and verify the # kerberos protocol for outbound and inbound auth. klist get krbtgt if(`$LASTEXITCODE) { Throw "Validation command 'klist get krbtgt' had exit code `${LASTEXITCODE}." } "@ } function Assert-ReadyDaemonSet { <# .SYNOPSIS Assert that the Kubernetes DaemonSet is ready. This function will assert that the ready pods count matches the desired pods count. .PARAMETER Name The DaemonSet name. #> Param( [Parameter(Mandatory=$true)] [String]$Name ) $daemonSet = Invoke-Kubectl "get daemonset ${Name} -o json" | ConvertFrom-Json $readyCount = $daemonSet.status.numberReady $desiredCount = $daemonSet.status.desiredNumberScheduled if($readyCount -ne $desiredCount) { Throw "The DaemonSet '${Name}' is not ready yet. Ready pods count: ${readyCount}. Expected: ${desiredCount}." } } function Assert-ValidGMSACredentialSpecName { <# .SYNOPSIS Assert that the gMSA credential spec name is valid. .PARAMETER Name The gMSA credential spec name. #> Param( [Parameter(Mandatory=$true)] [String]$Name ) if(!($_ -cmatch "^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$")) { Throw "The gMSA credential spec name is invalid. The name must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character." } return $true } function Assert-ValidAzureLocation { <# .SYNOPSIS Assert that the given Azure location is valid. .PARAMETER Location The Azure location. #> Param( [Parameter(Mandatory=$true)] [String]$Location ) foreach($loc in (Get-AzLocation)) { if($loc.Location -eq $Location) { return $true } } Throw "The Azure location '${Location}' is not valid." } function Assert-ValidAlphanumeric { <# .SYNOPSIS Assert that the given string is alphanumeric. .PARAMETER Name The string to validate. #> Param( [Parameter(Mandatory=$true)] [String]$Name ) if(!($Name -cmatch "^[a-zA-Z0-9]+$")) { Throw "Invalid input, '${Name}' is not alphanumeric." } return $true } function Assert-ValidResourceName { <# .SYNOPSIS This function will assert that the given string is a valid resource name. The allowed characters are letters, numbers, and the dash (-) character. The first character must be a letter, and the end character must be a letter or a number. .PARAMETER Name The string to validate. #> Param( [Parameter(Mandatory=$true)] [String]$Name ) if(!($Name -cmatch "^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$")) { Throw "Invalid input given. The allowed characters are letters, numbers, and the dash (-) character. The first character must be a letter, and the end character must be a letter or a number." } return $true } function Assert-ValidAzureResourceGroupName { <# .SYNOPSIS Assert that the given Azure resource group name is valid. .PARAMETER Name The Azure resource group name. #> Param( [Parameter(Mandatory=$true)] [String]$Name ) if(!($Name -match "^[-\w\._\(\)]*[-\w_\(\)]+$")) { Throw "Resource group names only allow alphanumeric characters, periods, underscores, hyphens and parenthesis and cannot end in a period." } if($Name.Length -gt 90) { Throw "Resource group names must be less than 90 characters." } return $true } function Assert-ValidDnsName { <# .SYNOPSIS This function will assert that the given string is a valid DNS name. .PARAMETER Name The string to validate. #> Param( [Parameter(Mandatory=$true)] [String]$Name ) if(!($Name -cmatch "^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?(\.[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?)*$")) { Throw "Invalid input given, '${Name}' is not a valid DNS name." } return $true } function Assert-ValidDomainName { <# .SYNOPSIS This function will assert that the given string is a valid domain name (NetBIOS name or FQDN). .PARAMETER Name The string to validate. #> Param( [Parameter(Mandatory=$true)] [String]$Name ) $validNetBiosName = $true try { Assert-ValidAlphanumeric $Name } catch { $validNetBiosName = $false } $validFQDN = $true try { Assert-ValidDnsName $Name } catch { $validFQDN = $false } if(!$validNetBiosName -and !$validFQDN) { Throw "Invalid domain name. The domain name must be a valid NetBIOS name or a valid FQDN." } } function Assert-ValidDomainUserName { <# .SYNOPSIS This function will assert that the given string is a valid domain user name with the format 'DOMAIN\USERNAME'. .PARAMETER Name The string to validate. #> Param( [Parameter(Mandatory=$true)] [String]$Name ) $split = $Name.Split('\') if($split.Count -ne 2) { Throw "The domain user must be given as 'DOMAIN\USERNAME'." } # Validate the domain name (NetBIOS name or FQDN) and the user name return ((Assert-ValidDomainName $split[0]) -and (Assert-ValidResourceName $split[1])) } function Assert-ValidFQDNDomainUserName { <# .SYNOPSIS This function will assert that the given string is a valid domain user name with the format 'FQDN\USERNAME'. .PARAMETER Name The string to validate. #> Param( [Parameter(Mandatory=$true)] [String]$Name ) $split = $Name.Split('\') if($split.Count -ne 2) { Throw "The domain user must be given as 'FQDN\USERNAME'." } return ((Assert-ValidDnsName $split[0]) -and (Assert-ValidResourceName $split[1])) } function Assert-ValidAksNodePoolNames { <# .SYNOPSIS This function will assert that the given AKS node pools' names are valid. .PARAMETER Names The node pools' names to validate. #> Param( [Parameter(Mandatory=$true)] [String]$Names ) foreach($name in $Names) { if($name.Length -gt 6) { Throw "The node pool name '$name' is too long. The maximum length is 6 characters." } Assert-ValidAlphanumeric $name | Out-Null } return $true } # SIG # Begin signature block # MIInlQYJKoZIhvcNAQcCoIInhjCCJ4ICAQExDzANBglghkgBZQMEAgEFADB5Bgor # BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG # KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCAb06wk6MrNz2ni # vU2EYpjFIMKFRt2ddHxRrZ/QK8nlgaCCDXYwggX0MIID3KADAgECAhMzAAACy7d1 # OfsCcUI2AAAAAALLMA0GCSqGSIb3DQEBCwUAMH4xCzAJBgNVBAYTAlVTMRMwEQYD # VQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNy # b3NvZnQgQ29ycG9yYXRpb24xKDAmBgNVBAMTH01pY3Jvc29mdCBDb2RlIFNpZ25p # bmcgUENBIDIwMTEwHhcNMjIwNTEyMjA0NTU5WhcNMjMwNTExMjA0NTU5WjB0MQsw # CQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9u # ZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMR4wHAYDVQQDExVNaWNy # b3NvZnQgQ29ycG9yYXRpb24wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB # AQC3sN0WcdGpGXPZIb5iNfFB0xZ8rnJvYnxD6Uf2BHXglpbTEfoe+mO//oLWkRxA # wppditsSVOD0oglKbtnh9Wp2DARLcxbGaW4YanOWSB1LyLRpHnnQ5POlh2U5trg4 # 3gQjvlNZlQB3lL+zrPtbNvMA7E0Wkmo+Z6YFnsf7aek+KGzaGboAeFO4uKZjQXY5 # RmMzE70Bwaz7hvA05jDURdRKH0i/1yK96TDuP7JyRFLOvA3UXNWz00R9w7ppMDcN # lXtrmbPigv3xE9FfpfmJRtiOZQKd73K72Wujmj6/Su3+DBTpOq7NgdntW2lJfX3X # a6oe4F9Pk9xRhkwHsk7Ju9E/AgMBAAGjggFzMIIBbzAfBgNVHSUEGDAWBgorBgEE # AYI3TAgBBggrBgEFBQcDAzAdBgNVHQ4EFgQUrg/nt/gj+BBLd1jZWYhok7v5/w4w # RQYDVR0RBD4wPKQ6MDgxHjAcBgNVBAsTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEW # MBQGA1UEBRMNMjMwMDEyKzQ3MDUyODAfBgNVHSMEGDAWgBRIbmTlUAXTgqoXNzci # tW2oynUClTBUBgNVHR8ETTBLMEmgR6BFhkNodHRwOi8vd3d3Lm1pY3Jvc29mdC5j # b20vcGtpb3BzL2NybC9NaWNDb2RTaWdQQ0EyMDExXzIwMTEtMDctMDguY3JsMGEG # CCsGAQUFBwEBBFUwUzBRBggrBgEFBQcwAoZFaHR0cDovL3d3dy5taWNyb3NvZnQu # Y29tL3BraW9wcy9jZXJ0cy9NaWNDb2RTaWdQQ0EyMDExXzIwMTEtMDctMDguY3J0 # MAwGA1UdEwEB/wQCMAAwDQYJKoZIhvcNAQELBQADggIBAJL5t6pVjIRlQ8j4dAFJ # ZnMke3rRHeQDOPFxswM47HRvgQa2E1jea2aYiMk1WmdqWnYw1bal4IzRlSVf4czf # zx2vjOIOiaGllW2ByHkfKApngOzJmAQ8F15xSHPRvNMmvpC3PFLvKMf3y5SyPJxh # 922TTq0q5epJv1SgZDWlUlHL/Ex1nX8kzBRhHvc6D6F5la+oAO4A3o/ZC05OOgm4 # EJxZP9MqUi5iid2dw4Jg/HvtDpCcLj1GLIhCDaebKegajCJlMhhxnDXrGFLJfX8j # 7k7LUvrZDsQniJZ3D66K+3SZTLhvwK7dMGVFuUUJUfDifrlCTjKG9mxsPDllfyck # 4zGnRZv8Jw9RgE1zAghnU14L0vVUNOzi/4bE7wIsiRyIcCcVoXRneBA3n/frLXvd # jDsbb2lpGu78+s1zbO5N0bhHWq4j5WMutrspBxEhqG2PSBjC5Ypi+jhtfu3+x76N # mBvsyKuxx9+Hm/ALnlzKxr4KyMR3/z4IRMzA1QyppNk65Ui+jB14g+w4vole33M1 # pVqVckrmSebUkmjnCshCiH12IFgHZF7gRwE4YZrJ7QjxZeoZqHaKsQLRMp653beB # fHfeva9zJPhBSdVcCW7x9q0c2HVPLJHX9YCUU714I+qtLpDGrdbZxD9mikPqL/To # /1lDZ0ch8FtePhME7houuoPcMIIHejCCBWKgAwIBAgIKYQ6Q0gAAAAAAAzANBgkq # hkiG9w0BAQsFADCBiDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24x # EDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlv # bjEyMDAGA1UEAxMpTWljcm9zb2Z0IFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5 # IDIwMTEwHhcNMTEwNzA4MjA1OTA5WhcNMjYwNzA4MjEwOTA5WjB+MQswCQYDVQQG # EwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwG # A1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMSgwJgYDVQQDEx9NaWNyb3NvZnQg # Q29kZSBTaWduaW5nIFBDQSAyMDExMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIIC # CgKCAgEAq/D6chAcLq3YbqqCEE00uvK2WCGfQhsqa+laUKq4BjgaBEm6f8MMHt03 # a8YS2AvwOMKZBrDIOdUBFDFC04kNeWSHfpRgJGyvnkmc6Whe0t+bU7IKLMOv2akr # rnoJr9eWWcpgGgXpZnboMlImEi/nqwhQz7NEt13YxC4Ddato88tt8zpcoRb0Rrrg # OGSsbmQ1eKagYw8t00CT+OPeBw3VXHmlSSnnDb6gE3e+lD3v++MrWhAfTVYoonpy # 4BI6t0le2O3tQ5GD2Xuye4Yb2T6xjF3oiU+EGvKhL1nkkDstrjNYxbc+/jLTswM9 # sbKvkjh+0p2ALPVOVpEhNSXDOW5kf1O6nA+tGSOEy/S6A4aN91/w0FK/jJSHvMAh # dCVfGCi2zCcoOCWYOUo2z3yxkq4cI6epZuxhH2rhKEmdX4jiJV3TIUs+UsS1Vz8k # A/DRelsv1SPjcF0PUUZ3s/gA4bysAoJf28AVs70b1FVL5zmhD+kjSbwYuER8ReTB # w3J64HLnJN+/RpnF78IcV9uDjexNSTCnq47f7Fufr/zdsGbiwZeBe+3W7UvnSSmn # Eyimp31ngOaKYnhfsi+E11ecXL93KCjx7W3DKI8sj0A3T8HhhUSJxAlMxdSlQy90 # lfdu+HggWCwTXWCVmj5PM4TasIgX3p5O9JawvEagbJjS4NaIjAsCAwEAAaOCAe0w # ggHpMBAGCSsGAQQBgjcVAQQDAgEAMB0GA1UdDgQWBBRIbmTlUAXTgqoXNzcitW2o # ynUClTAZBgkrBgEEAYI3FAIEDB4KAFMAdQBiAEMAQTALBgNVHQ8EBAMCAYYwDwYD # VR0TAQH/BAUwAwEB/zAfBgNVHSMEGDAWgBRyLToCMZBDuRQFTuHqp8cx0SOJNDBa # BgNVHR8EUzBRME+gTaBLhklodHRwOi8vY3JsLm1pY3Jvc29mdC5jb20vcGtpL2Ny # bC9wcm9kdWN0cy9NaWNSb29DZXJBdXQyMDExXzIwMTFfMDNfMjIuY3JsMF4GCCsG # AQUFBwEBBFIwUDBOBggrBgEFBQcwAoZCaHR0cDovL3d3dy5taWNyb3NvZnQuY29t # L3BraS9jZXJ0cy9NaWNSb29DZXJBdXQyMDExXzIwMTFfMDNfMjIuY3J0MIGfBgNV # HSAEgZcwgZQwgZEGCSsGAQQBgjcuAzCBgzA/BggrBgEFBQcCARYzaHR0cDovL3d3 # dy5taWNyb3NvZnQuY29tL3BraW9wcy9kb2NzL3ByaW1hcnljcHMuaHRtMEAGCCsG # AQUFBwICMDQeMiAdAEwAZQBnAGEAbABfAHAAbwBsAGkAYwB5AF8AcwB0AGEAdABl # AG0AZQBuAHQALiAdMA0GCSqGSIb3DQEBCwUAA4ICAQBn8oalmOBUeRou09h0ZyKb # C5YR4WOSmUKWfdJ5DJDBZV8uLD74w3LRbYP+vj/oCso7v0epo/Np22O/IjWll11l # hJB9i0ZQVdgMknzSGksc8zxCi1LQsP1r4z4HLimb5j0bpdS1HXeUOeLpZMlEPXh6 # I/MTfaaQdION9MsmAkYqwooQu6SpBQyb7Wj6aC6VoCo/KmtYSWMfCWluWpiW5IP0 # wI/zRive/DvQvTXvbiWu5a8n7dDd8w6vmSiXmE0OPQvyCInWH8MyGOLwxS3OW560 # STkKxgrCxq2u5bLZ2xWIUUVYODJxJxp/sfQn+N4sOiBpmLJZiWhub6e3dMNABQam # ASooPoI/E01mC8CzTfXhj38cbxV9Rad25UAqZaPDXVJihsMdYzaXht/a8/jyFqGa # J+HNpZfQ7l1jQeNbB5yHPgZ3BtEGsXUfFL5hYbXw3MYbBL7fQccOKO7eZS/sl/ah # XJbYANahRr1Z85elCUtIEJmAH9AAKcWxm6U/RXceNcbSoqKfenoi+kiVH6v7RyOA # 9Z74v2u3S5fi63V4GuzqN5l5GEv/1rMjaHXmr/r8i+sLgOppO6/8MO0ETI7f33Vt # Y5E90Z1WTk+/gFcioXgRMiF670EKsT/7qMykXcGhiJtXcVZOSEXAQsmbdlsKgEhr # /Xmfwb1tbWrJUnMTDXpQzTGCGXUwghlxAgEBMIGVMH4xCzAJBgNVBAYTAlVTMRMw # EQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVN # aWNyb3NvZnQgQ29ycG9yYXRpb24xKDAmBgNVBAMTH01pY3Jvc29mdCBDb2RlIFNp # Z25pbmcgUENBIDIwMTECEzMAAALLt3U5+wJxQjYAAAAAAsswDQYJYIZIAWUDBAIB # BQCggbAwGQYJKoZIhvcNAQkDMQwGCisGAQQBgjcCAQQwHAYKKwYBBAGCNwIBCzEO # MAwGCisGAQQBgjcCARUwLwYJKoZIhvcNAQkEMSIEIDJ0TVqMKwWkcT9Rzv1VvTfy # enK/ghGvA257cz/1R85EMEQGCisGAQQBgjcCAQwxNjA0oBSAEgBNAGkAYwByAG8A # cwBvAGYAdKEcgBpodHRwczovL3d3dy5taWNyb3NvZnQuY29tIDANBgkqhkiG9w0B # AQEFAASCAQAJGLv2KKr2xX2FjUfAPJvILoZgP36CHddhPtnLn92uGdUgOIHP8n3A # VKog64t1ToK2yG/p8CNDXzSuyE/JtAB6ebSAhF4E+n4YudgR99hBsEnLArSrrvad # Cz2uLHp/ZXtbaoEQdlTcxrAo3V3Hmb4bwqYkh33aMLksQ3s+OC99UwLPtvXS2aGS # xv8+LTo3CcRd+FrjzNho2uX+En8UdmUiKlbMdocJErjanPM1IWEOjA071LiWiUrX # 8Ax1iY/YvWaUlJs6o3iMfKxrpSfcWwcda5gG98zj++c7WB59XGbKOgtmFoqwGAlc # rPmdQPdfE7ZU6bgtf/6afbH2C6xeO8cZoYIW/TCCFvkGCisGAQQBgjcDAwExghbp # MIIW5QYJKoZIhvcNAQcCoIIW1jCCFtICAQMxDzANBglghkgBZQMEAgEFADCCAVEG # CyqGSIb3DQEJEAEEoIIBQASCATwwggE4AgEBBgorBgEEAYRZCgMBMDEwDQYJYIZI # AWUDBAIBBQAEIKHvvjcMN7HmwC8HNW4Ghe3hNLC8oIGWN5/urb+gZ47fAgZjmwcw # u0wYEzIwMjMwMTA5MTgyMDExLjA1NFowBIACAfSggdCkgc0wgcoxCzAJBgNVBAYT # AlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYD # VQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xJTAjBgNVBAsTHE1pY3Jvc29mdCBB # bWVyaWNhIE9wZXJhdGlvbnMxJjAkBgNVBAsTHVRoYWxlcyBUU1MgRVNOOkREOEMt # RTMzNy0yRkFFMSUwIwYDVQQDExxNaWNyb3NvZnQgVGltZS1TdGFtcCBTZXJ2aWNl # oIIRVDCCBwwwggT0oAMCAQICEzMAAAHFA83NIaH07zkAAQAAAcUwDQYJKoZIhvcN # AQELBQAwfDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNV # BAcTB1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEmMCQG # A1UEAxMdTWljcm9zb2Z0IFRpbWUtU3RhbXAgUENBIDIwMTAwHhcNMjIxMTA0MTkw # MTMyWhcNMjQwMjAyMTkwMTMyWjCByjELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldh # c2hpbmd0b24xEDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBD # b3Jwb3JhdGlvbjElMCMGA1UECxMcTWljcm9zb2Z0IEFtZXJpY2EgT3BlcmF0aW9u # czEmMCQGA1UECxMdVGhhbGVzIFRTUyBFU046REQ4Qy1FMzM3LTJGQUUxJTAjBgNV # BAMTHE1pY3Jvc29mdCBUaW1lLVN0YW1wIFNlcnZpY2UwggIiMA0GCSqGSIb3DQEB # AQUAA4ICDwAwggIKAoICAQCrSF2zvR5fbcnulqmlopdGHP5NPsknc69V/f43x82n # FGzmNjiES/cFX/DkRZdtl07ibfGPTWVMj/EOSr7K2O6I97zEZexnEOe2/svUTMx3 # mMhKon55i7ySBXTnqaqzx0GjnnFk889zF/m7X3OfThoxAXk9dX8LhktKMVr0gU1y # uJt06beUZbWtBEVraNSy6nqC/rfirlTAfT1YYa7TPz1Fu1vIznm+YGBZXx53ptkJ # mtyhgiMwvwVFO8aXOeqboe3Bl1czAodPdr+QtRI+IYCysiATPPs2kGl46yCz1OvD # JZNkE1sHDIgAKZDfiP65Hh63aFmT40fj0qEQnJgPb504hoMYHYRQ0VJhzLUySC1m # 3V5GoEHSb5g9jPseOhw/KQpg1BntO/7OCU598KJrHWM5vS7ohgLlfUmvwDBNyxoP # K7eoCHHxwVA30MOCJVnD5REVnyjKgOTqwhXWfHnNkvL6E21qR49f1LtjyfWpZ8CO # hc8TorT91tPDzsQ4kv8GUkZwqgVPK2vTM+D8w0lJvp/Zr/AORegYIZYmJCsZPGM4 # /5H3r+cggbTl4TUumTLYU51gw8HgOFbu0F1lq616lNO5KGaCf4YoRHwCgDWBJKTU # QLllfhymlWeAmluUwG7yv+0KF8dV1e+JjqENKEfBAKZmpl5uBJgeceXi6sT7grpk # LwIDAQABo4IBNjCCATIwHQYDVR0OBBYEFFTquzi/WbE1gb+u2kvCtXB6TQVrMB8G # A1UdIwQYMBaAFJ+nFV0AXmJdg/Tl0mWnG1M1GelyMF8GA1UdHwRYMFYwVKBSoFCG # Tmh0dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2lvcHMvY3JsL01pY3Jvc29mdCUy # MFRpbWUtU3RhbXAlMjBQQ0ElMjAyMDEwKDEpLmNybDBsBggrBgEFBQcBAQRgMF4w # XAYIKwYBBQUHMAKGUGh0dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2lvcHMvY2Vy # dHMvTWljcm9zb2Z0JTIwVGltZS1TdGFtcCUyMFBDQSUyMDIwMTAoMSkuY3J0MAwG # A1UdEwEB/wQCMAAwEwYDVR0lBAwwCgYIKwYBBQUHAwgwDQYJKoZIhvcNAQELBQAD # ggIBAIyo3nx+swc5JxyIr4J2evp0rx9OyBAN5n1u9CMK7E0glkn3b7Gl4pEJ/der # jup1HKSQpSdkLp0eEvC3V+HDKLL8t91VD3J/WFhn9GlNL7PSGdqgr4/8gMCJQ2bf # Y1cuEMG7Q/hJv+4JXiM641RyYmGmkFCBBWEXH/nsliTUsJ2Mh57/8atx9uRC2Jih # v05r3cNKNuwPWOpqJwSeRyVQ3+YSb1mycKcDX785AOn/xDhw98f3gszgnpfQ200F # 5XLC9YfTC4xo4nMeAMsJ4lSQUT0cTywENV52aPrM8kAj7ujMuNirDuLhEVuJK19Z # lIaPC36UslBlFZQJxPdodi9OjVhYNmySiFaDvvD18XZBuI70N+eqhntCjMeLtGI+ # luOCQkwCGuGl5N/9q3Z734diQo5tSaA8CsfVaOK/CbV3s9haxqsvu7mpm6TfoZvW # YRNLWgDZdff4LeuC3NGiE/z2plV/v2VW+OaDfg20gIr+kyT31IG62CG2KkVIxB1t # dSdLah4u31wq6/Uwm76AnzepdM2RDZCqHG01G9sT1CqaolDDlVb/hJnN7Wk9fHI5 # M7nIOr6JEhS5up5DOZRwKSLI24IsdaHw4sIjmYg4LWIu1UN/aXD15auinC7lIMm1 # P9nCohTWpvZT42OQ1yPWFs4MFEQtpNNZ33VEmJQj2dwmQaD+MIIHcTCCBVmgAwIB # AgITMwAAABXF52ueAptJmQAAAAAAFTANBgkqhkiG9w0BAQsFADCBiDELMAkGA1UE # BhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1JlZG1vbmQxHjAc # BgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEyMDAGA1UEAxMpTWljcm9zb2Z0 # IFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5IDIwMTAwHhcNMjEwOTMwMTgyMjI1 # WhcNMzAwOTMwMTgzMjI1WjB8MQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGlu # Z3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBv # cmF0aW9uMSYwJAYDVQQDEx1NaWNyb3NvZnQgVGltZS1TdGFtcCBQQ0EgMjAxMDCC # AiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAOThpkzntHIhC3miy9ckeb0O # 1YLT/e6cBwfSqWxOdcjKNVf2AX9sSuDivbk+F2Az/1xPx2b3lVNxWuJ+Slr+uDZn # hUYjDLWNE893MsAQGOhgfWpSg0S3po5GawcU88V29YZQ3MFEyHFcUTE3oAo4bo3t # 1w/YJlN8OWECesSq/XJprx2rrPY2vjUmZNqYO7oaezOtgFt+jBAcnVL+tuhiJdxq # D89d9P6OU8/W7IVWTe/dvI2k45GPsjksUZzpcGkNyjYtcI4xyDUoveO0hyTD4MmP # frVUj9z6BVWYbWg7mka97aSueik3rMvrg0XnRm7KMtXAhjBcTyziYrLNueKNiOSW # rAFKu75xqRdbZ2De+JKRHh09/SDPc31BmkZ1zcRfNN0Sidb9pSB9fvzZnkXftnIv # 231fgLrbqn427DZM9ituqBJR6L8FA6PRc6ZNN3SUHDSCD/AQ8rdHGO2n6Jl8P0zb # r17C89XYcz1DTsEzOUyOArxCaC4Q6oRRRuLRvWoYWmEBc8pnol7XKHYC4jMYcten # IPDC+hIK12NvDMk2ZItboKaDIV1fMHSRlJTYuVD5C4lh8zYGNRiER9vcG9H9stQc # xWv2XFJRXRLbJbqvUAV6bMURHXLvjflSxIUXk8A8FdsaN8cIFRg/eKtFtvUeh17a # j54WcmnGrnu3tz5q4i6tAgMBAAGjggHdMIIB2TASBgkrBgEEAYI3FQEEBQIDAQAB # MCMGCSsGAQQBgjcVAgQWBBQqp1L+ZMSavoKRPEY1Kc8Q/y8E7jAdBgNVHQ4EFgQU # n6cVXQBeYl2D9OXSZacbUzUZ6XIwXAYDVR0gBFUwUzBRBgwrBgEEAYI3TIN9AQEw # QTA/BggrBgEFBQcCARYzaHR0cDovL3d3dy5taWNyb3NvZnQuY29tL3BraW9wcy9E # b2NzL1JlcG9zaXRvcnkuaHRtMBMGA1UdJQQMMAoGCCsGAQUFBwMIMBkGCSsGAQQB # gjcUAgQMHgoAUwB1AGIAQwBBMAsGA1UdDwQEAwIBhjAPBgNVHRMBAf8EBTADAQH/ # MB8GA1UdIwQYMBaAFNX2VsuP6KJcYmjRPZSQW9fOmhjEMFYGA1UdHwRPME0wS6BJ # oEeGRWh0dHA6Ly9jcmwubWljcm9zb2Z0LmNvbS9wa2kvY3JsL3Byb2R1Y3RzL01p # Y1Jvb0NlckF1dF8yMDEwLTA2LTIzLmNybDBaBggrBgEFBQcBAQROMEwwSgYIKwYB # BQUHMAKGPmh0dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2kvY2VydHMvTWljUm9v # Q2VyQXV0XzIwMTAtMDYtMjMuY3J0MA0GCSqGSIb3DQEBCwUAA4ICAQCdVX38Kq3h # LB9nATEkW+Geckv8qW/qXBS2Pk5HZHixBpOXPTEztTnXwnE2P9pkbHzQdTltuw8x # 5MKP+2zRoZQYIu7pZmc6U03dmLq2HnjYNi6cqYJWAAOwBb6J6Gngugnue99qb74p # y27YP0h1AdkY3m2CDPVtI1TkeFN1JFe53Z/zjj3G82jfZfakVqr3lbYoVSfQJL1A # oL8ZthISEV09J+BAljis9/kpicO8F7BUhUKz/AyeixmJ5/ALaoHCgRlCGVJ1ijbC # HcNhcy4sa3tuPywJeBTpkbKpW99Jo3QMvOyRgNI95ko+ZjtPu4b6MhrZlvSP9pEB # 9s7GdP32THJvEKt1MMU0sHrYUP4KWN1APMdUbZ1jdEgssU5HLcEUBHG/ZPkkvnNt # yo4JvbMBV0lUZNlz138eW0QBjloZkWsNn6Qo3GcZKCS6OEuabvshVGtqRRFHqfG3 # rsjoiV5PndLQTHa1V1QJsWkBRH58oWFsc/4Ku+xBZj1p/cvBQUl+fpO+y/g75LcV # v7TOPqUxUYS8vwLBgqJ7Fx0ViY1w/ue10CgaiQuPNtq6TPmb/wrpNPgkNWcr4A24 # 5oyZ1uEi6vAnQj0llOZ0dFtq0Z4+7X6gMTN9vMvpe784cETRkPHIqzqKOghif9lw # Y1NNje6CbaUFEMFxBmoQtB1VM1izoXBm8qGCAsswggI0AgEBMIH4oYHQpIHNMIHK # MQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVk # bW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMSUwIwYDVQQLExxN # aWNyb3NvZnQgQW1lcmljYSBPcGVyYXRpb25zMSYwJAYDVQQLEx1UaGFsZXMgVFNT # IEVTTjpERDhDLUUzMzctMkZBRTElMCMGA1UEAxMcTWljcm9zb2Z0IFRpbWUtU3Rh # bXAgU2VydmljZaIjCgEBMAcGBSsOAwIaAxUAIQAa9hdkkrtxSjrb4u8RhATHv+eg # gYMwgYCkfjB8MQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4G # A1UEBxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMSYw # JAYDVQQDEx1NaWNyb3NvZnQgVGltZS1TdGFtcCBQQ0EgMjAxMDANBgkqhkiG9w0B # AQUFAAIFAOdmetkwIhgPMjAyMzAxMDkxOTM2NTdaGA8yMDIzMDExMDE5MzY1N1ow # dDA6BgorBgEEAYRZCgQBMSwwKjAKAgUA52Z62QIBADAHAgEAAgIi+DAHAgEAAgIR # sDAKAgUA52fMWQIBADA2BgorBgEEAYRZCgQCMSgwJjAMBgorBgEEAYRZCgMCoAow # CAIBAAIDB6EgoQowCAIBAAIDAYagMA0GCSqGSIb3DQEBBQUAA4GBAE/d3mMiBdAb # LwOJd8Aid6u4Oa2if/JJfS891s9E2CDPiYi1k8MO+PgMR5zp1irTY5mVF1/H2tlP # w5SPJ/K9IVC7ZBsjXXCHi9nf6vuolW42l/rXNDplX9O0yc7O/kVhnkiC7lT7CDEC # RPjKgK2FzNmxaPSV5hgP02uMEEALcdF5MYIEDTCCBAkCAQEwgZMwfDELMAkGA1UE # BhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1JlZG1vbmQxHjAc # BgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEmMCQGA1UEAxMdTWljcm9zb2Z0 # IFRpbWUtU3RhbXAgUENBIDIwMTACEzMAAAHFA83NIaH07zkAAQAAAcUwDQYJYIZI # AWUDBAIBBQCgggFKMBoGCSqGSIb3DQEJAzENBgsqhkiG9w0BCRABBDAvBgkqhkiG # 9w0BCQQxIgQg7U1qlU67oQK5Ja6ljoTrkKPsP3wj39tvt+jJClAU0z8wgfoGCyqG # SIb3DQEJEAIvMYHqMIHnMIHkMIG9BCAZAbGR9iR3TAr5XT3A7Sw76ybyAAzKPkS4 # o+q81D98sTCBmDCBgKR+MHwxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5n # dG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9y # YXRpb24xJjAkBgNVBAMTHU1pY3Jvc29mdCBUaW1lLVN0YW1wIFBDQSAyMDEwAhMz # AAABxQPNzSGh9O85AAEAAAHFMCIEIO8Cpyg9DFK8PsyXlsjtUNv325vq+Gtyl6Ay # KMnXeX21MA0GCSqGSIb3DQEBCwUABIICAGG9dcyW9rUefClhQpgEuPq44vq3pi/U # X1B8zIPECF9EOYpSpgrjBYkTyTYCwM0kxqcMghV6WiS9AdSnYlD9e2nw6mAPNmql # szevIbm41vl6JL93W6VKBjjwQaioRbqLsg0TPuH4Vwcn+hKd8E/StHQsy8Fg0tI4 # 8PkOZNwUrDu4QHOsbcb6ViHaOzPegMxJ3sshQ0yg3tr11xh2juMzx4NGhoG6aXEl # j2jb7YlNUiUCezl67xB7g2ktyMKdlhSRIn6V+qrB4gJMj2wiqpabTHK5dj3oPcu4 # aHRpA/YJkt6iaVpQSLLxwit98dKoaLNMBzTXqT1TRHA6sPYOO9aIgIk1Gu+u8MX0 # ddO/rrOaRIp9dx/iEERU8wkQYPz2iX0JlfZnvAVZZg78uDJV2OztEbo9ZRUSMXs7 # Gt3xfxVcCfhnZmXs6bjU7x5UgPcSyk5swkI8SVtrOkoe0n/pHs2rrtL5ykQ/A1b4 # 8m0W9V0IdmXwPbhD9hOoiXdTnWbakZ9J+cUPB04AT2O+5rMnEV/shXx3mTmMDOOq # NDo8R4VJconNBHjvMWD6ssbvkK7Nuk6tVivuGgj2Y0tY0tlCBoMaLPnaHlaMMuEX # T9ydFZ4217sVMRlYn3/cHTuvoJz3z6K3QL/4YYZnuYqIiWJquAujAu27OwnL7Woc # FSsy9TnSrNbU # SIG # End signature block |