Created
July 13, 2026 14:18
-
-
Save supermarsx/eadc06905ac9a648b738808ad2e7bb1b to your computer and use it in GitHub Desktop.
Automatically selects and applies the newest valid public certificate to Microsoft Exchange Server services and SMTP connectors
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #requires -Version 5.1 | |
| #requires -RunAsAdministrator | |
| <# | |
| .SYNOPSIS | |
| Automatically selects and applies the newest valid public certificate to | |
| Microsoft Exchange Server services and SMTP connectors. | |
| .DESCRIPTION | |
| This script: | |
| * Loads the local Exchange Management Shell cmdlets when needed. | |
| * Finds the newest valid, non-self-signed certificate matching TargetFqdn. | |
| * Requires a private key and Server Authentication EKU. | |
| * Optionally requires a locally trusted certificate chain. | |
| * Assigns IIS, SMTP, POP, and IMAP Exchange services. | |
| * Pins selected Receive and Send connectors using TlsCertificateName. | |
| * Updates public-facing connector FQDNs when requested. | |
| * Supports WhatIf, detailed logging, state tracking, and safe verification. | |
| * Can optionally restart affected services. | |
| * Can optionally remove expired or superseded matching public certificates. | |
| The script does not request or renew a certificate from a CA. The renewed | |
| certificate must already exist in LocalMachine\My and be visible through | |
| Get-ExchangeCertificate. | |
| IMPORTANT SMTP NOTE: | |
| Exchange prompts when assigning SMTP to a new certificate because doing so | |
| may replace the default internal transport certificate. Fully unattended | |
| SMTP assignment therefore requires -AllowReplaceInternalTransportCertificate. | |
| Without that switch, the script will only proceed if the selected certificate | |
| is already assigned to SMTP. | |
| .PARAMETER TargetFqdn | |
| Public DNS name that must be present in the certificate SAN or CN. | |
| .PARAMETER Server | |
| Exchange server name. Defaults to the local computer name. | |
| .PARAMETER Services | |
| Exchange services to assign. Defaults to IIS, SMTP, POP, and IMAP. | |
| .PARAMETER ReceivePorts | |
| Receive connector local ports to update when ReceiveConnectorNames is not | |
| supplied. Defaults to ports 25 and 587. | |
| .PARAMETER ReceiveConnectorNames | |
| Exact Receive connector names to update. When omitted, the script selects | |
| FrontendTransport connectors whose bindings use ReceivePorts. | |
| .PARAMETER SendConnectorNames | |
| Exact Send connector names to update. When omitted, the script selects Send | |
| connectors whose Fqdn already equals TargetFqdn. | |
| .PARAMETER IncludeNonFrontendReceiveConnectors | |
| Include HubTransport/non-frontend Receive connectors in automatic selection. | |
| .PARAMETER DoNotUpdateConnectorFqdn | |
| Pin TlsCertificateName but leave connector Fqdn unchanged. | |
| .PARAMETER AllowReplaceInternalTransportCertificate | |
| Permit unattended SMTP assignment with Enable-ExchangeCertificate -Force. | |
| This can replace Exchange's default internal transport certificate. | |
| .PARAMETER RequireTrustedChain | |
| Reject a candidate whose local certificate chain cannot be built. Chain | |
| validation uses the local machine stores and does not perform revocation | |
| checking. Enabled by default. | |
| .PARAMETER MinValidityDays | |
| Minimum remaining validity required for a candidate. Defaults to 7 days. | |
| .PARAMETER RestartAffectedServices | |
| Restart running Exchange/IIS services affected by an actual change. Restart | |
| failures are logged as warnings and do not roll back certificate changes. | |
| .PARAMETER RemoveExpiredMatchingCertificates | |
| Attempt to remove expired, non-self-signed certificates matching TargetFqdn. | |
| .PARAMETER RemoveSupersededMatchingCertificates | |
| Attempt to remove older matching public certificates after CleanupGraceDays. | |
| The selected certificate and KeepPreviousCertificates newest predecessors | |
| are retained. Removal failures, including connector references, are logged. | |
| .PARAMETER CleanupGraceDays | |
| Minimum number of days after the selected certificate's NotBefore date before | |
| superseded-certificate cleanup can occur. Defaults to 14. | |
| .PARAMETER KeepPreviousCertificates | |
| Number of previous matching certificates to retain. Defaults to 1. | |
| .PARAMETER LogDirectory | |
| Directory for logs, state, and JSON reports. | |
| .EXAMPLE | |
| .\Update-ExchangeCertificate.ps1 ` | |
| -TargetFqdn remote.vogue-homes.com ` | |
| -Server VOGUE-A3 ` | |
| -AllowReplaceInternalTransportCertificate ` | |
| -RestartAffectedServices | |
| .EXAMPLE | |
| .\Update-ExchangeCertificate.ps1 ` | |
| -TargetFqdn remote.vogue-homes.com ` | |
| -Server VOGUE-A3 ` | |
| -ReceiveConnectorNames "Default Frontend VOGUE-A3","Default Frontend VOGUE-A3 (PORT 25)" ` | |
| -SendConnectorNames "External MTA (sapo)","MTA2","MTA MEO" ` | |
| -AllowReplaceInternalTransportCertificate ` | |
| -WhatIf | |
| .EXAMPLE | |
| .\Update-ExchangeCertificate.ps1 ` | |
| -TargetFqdn remote.vogue-homes.com ` | |
| -Server VOGUE-A3 ` | |
| -AllowReplaceInternalTransportCertificate ` | |
| -RemoveExpiredMatchingCertificates | |
| #> | |
| [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] | |
| param( | |
| [Parameter(Mandatory = $true)] | |
| [ValidateNotNullOrEmpty()] | |
| [string]$TargetFqdn, | |
| [ValidateNotNullOrEmpty()] | |
| [string]$Server = $env:COMPUTERNAME, | |
| [ValidateSet('IIS', 'SMTP', 'POP', 'IMAP')] | |
| [string[]]$Services = @('IIS', 'SMTP', 'POP', 'IMAP'), | |
| [ValidateRange(1, 65535)] | |
| [int[]]$ReceivePorts = @(25, 587), | |
| [string[]]$ReceiveConnectorNames = @(), | |
| [string[]]$SendConnectorNames = @(), | |
| [switch]$IncludeNonFrontendReceiveConnectors, | |
| [switch]$DoNotUpdateConnectorFqdn, | |
| [switch]$AllowReplaceInternalTransportCertificate, | |
| [bool]$RequireTrustedChain = $true, | |
| [ValidateRange(0, 3650)] | |
| [int]$MinValidityDays = 7, | |
| [switch]$RestartAffectedServices, | |
| [switch]$RemoveExpiredMatchingCertificates, | |
| [switch]$RemoveSupersededMatchingCertificates, | |
| [ValidateRange(0, 3650)] | |
| [int]$CleanupGraceDays = 14, | |
| [ValidateRange(0, 20)] | |
| [int]$KeepPreviousCertificates = 1, | |
| [string]$LogDirectory = "$env:ProgramData\ExchangeCertificateAutoUpdate" | |
| ) | |
| Set-StrictMode -Version Latest | |
| $ErrorActionPreference = 'Stop' | |
| $script:RunStarted = Get-Date | |
| $script:ChangesMade = $false | |
| $script:IisChanged = $false | |
| $script:SmtpChanged = $false | |
| $script:PopChanged = $false | |
| $script:ImapChanged = $false | |
| $script:Warnings = New-Object System.Collections.Generic.List[string] | |
| $script:Actions = New-Object System.Collections.Generic.List[object] | |
| $TargetFqdn = $TargetFqdn.Trim().TrimEnd('.').ToLowerInvariant() | |
| $Server = $Server.Trim() | |
| if (-not (Test-Path -LiteralPath $LogDirectory)) { | |
| New-Item -ItemType Directory -Path $LogDirectory -Force | Out-Null | |
| } | |
| $timestamp = Get-Date -Format 'yyyyMMdd-HHmmss' | |
| $script:LogFile = Join-Path $LogDirectory "ExchangeCertificateAutoUpdate-$timestamp.log" | |
| $stateFile = Join-Path $LogDirectory 'state.json' | |
| $reportFile = Join-Path $LogDirectory "report-$timestamp.json" | |
| function Write-Log { | |
| param( | |
| [Parameter(Mandatory = $true)] | |
| [string]$Message, | |
| [ValidateSet('INFO', 'WARN', 'ERROR', 'SUCCESS', 'DEBUG')] | |
| [string]$Level = 'INFO' | |
| ) | |
| $line = '{0:yyyy-MM-dd HH:mm:ss.fff zzz} [{1}] {2}' -f (Get-Date), $Level, $Message | |
| Add-Content -LiteralPath $script:LogFile -Value $line -Encoding UTF8 | |
| switch ($Level) { | |
| 'ERROR' { Write-Host $line -ForegroundColor Red } | |
| 'WARN' { Write-Host $line -ForegroundColor Yellow } | |
| 'SUCCESS' { Write-Host $line -ForegroundColor Green } | |
| 'DEBUG' { Write-Verbose $line } | |
| default { Write-Host $line } | |
| } | |
| } | |
| function Add-ActionRecord { | |
| param( | |
| [string]$Type, | |
| [string]$Target, | |
| [string]$Before, | |
| [string]$After, | |
| [string]$Result | |
| ) | |
| $script:Actions.Add([pscustomobject]@{ | |
| Time = Get-Date | |
| Type = $Type | |
| Target = $Target | |
| Before = $Before | |
| After = $After | |
| Result = $Result | |
| }) | Out-Null | |
| } | |
| function Add-WarningRecord { | |
| param([string]$Message) | |
| $script:Warnings.Add($Message) | Out-Null | |
| Write-Log -Message $Message -Level WARN | |
| } | |
| function Import-ExchangeCommandEnvironment { | |
| if (Get-Command Get-ExchangeCertificate -ErrorAction SilentlyContinue) { | |
| Write-Log 'Exchange Management Shell cmdlets are already available.' -Level DEBUG | |
| return | |
| } | |
| Write-Log 'Exchange cmdlets are not loaded. Attempting to load the registered Exchange PowerShell snap-in.' | |
| if (-not (Get-Command Get-PSSnapin -ErrorAction SilentlyContinue)) { | |
| throw 'Get-PSSnapin is unavailable. Run this script in Windows PowerShell 5.1 on the Exchange server.' | |
| } | |
| $registeredSnapIns = @( | |
| Get-PSSnapin -Registered | | |
| Where-Object { $_.Name -like 'Microsoft.Exchange.Management.PowerShell*' } | |
| ) | |
| if ($registeredSnapIns.Count -eq 0) { | |
| throw 'No registered Exchange Management PowerShell snap-in was found. Run the script from the Exchange Management Shell.' | |
| } | |
| $loaded = $false | |
| foreach ($snapIn in $registeredSnapIns) { | |
| try { | |
| if (-not (Get-PSSnapin -Name $snapIn.Name -ErrorAction SilentlyContinue)) { | |
| Add-PSSnapin -Name $snapIn.Name -ErrorAction Stop | |
| } | |
| if (Get-Command Get-ExchangeCertificate -ErrorAction SilentlyContinue) { | |
| Write-Log ("Loaded Exchange PowerShell snap-in: {0}" -f $snapIn.Name) -Level SUCCESS | |
| $loaded = $true | |
| break | |
| } | |
| } | |
| catch { | |
| Write-Log ("Failed to load snap-in {0}: {1}" -f $snapIn.Name, $_.Exception.Message) -Level DEBUG | |
| } | |
| } | |
| if (-not $loaded) { | |
| throw 'Unable to load Exchange Management Shell cmdlets.' | |
| } | |
| } | |
| function Test-DnsNameMatch { | |
| param( | |
| [Parameter(Mandatory = $true)] | |
| [string[]]$Names, | |
| [Parameter(Mandatory = $true)] | |
| [string]$DnsName | |
| ) | |
| $normalizedDns = $DnsName.Trim().TrimEnd('.').ToLowerInvariant() | |
| foreach ($rawName in $Names) { | |
| if ([string]::IsNullOrWhiteSpace($rawName)) { | |
| continue | |
| } | |
| $name = $rawName.Trim().TrimEnd('.').ToLowerInvariant() | |
| if ($name -eq $normalizedDns) { | |
| return $true | |
| } | |
| if ($name.StartsWith('*.')) { | |
| $suffix = $name.Substring(1) | |
| if ($normalizedDns.EndsWith($suffix, [System.StringComparison]::OrdinalIgnoreCase)) { | |
| $leftLabel = $normalizedDns.Substring(0, $normalizedDns.Length - $suffix.Length) | |
| if (-not [string]::IsNullOrWhiteSpace($leftLabel) -and $leftLabel.IndexOf('.') -lt 0) { | |
| return $true | |
| } | |
| } | |
| } | |
| } | |
| return $false | |
| } | |
| function Get-CertificateNames { | |
| param($ExchangeCertificate) | |
| $names = New-Object System.Collections.Generic.List[string] | |
| foreach ($domain in @($ExchangeCertificate.CertificateDomains)) { | |
| if ($null -ne $domain) { | |
| $value = $domain.ToString() | |
| if (-not [string]::IsNullOrWhiteSpace($value)) { | |
| $names.Add($value) | Out-Null | |
| } | |
| } | |
| } | |
| if ($names.Count -eq 0 -and [string]$ExchangeCertificate.Subject -match '(?i)(?:^|,\s*)CN\s*=\s*([^,]+)') { | |
| $names.Add($Matches[1].Trim()) | Out-Null | |
| } | |
| return $names.ToArray() | |
| } | |
| function Test-ServerAuthenticationEku { | |
| param( | |
| [Parameter(Mandatory = $true)] | |
| [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate | |
| ) | |
| $ekuExtension = $Certificate.Extensions | | |
| Where-Object { $_.Oid.Value -eq '2.5.29.37' } | | |
| Select-Object -First 1 | |
| # Absence of EKU means all application purposes are allowed. | |
| if ($null -eq $ekuExtension) { | |
| return $true | |
| } | |
| $typedEku = New-Object System.Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension | |
| $typedEku.CopyFrom($ekuExtension) | |
| foreach ($oid in $typedEku.EnhancedKeyUsages) { | |
| if ($oid.Value -eq '1.3.6.1.5.5.7.3.1') { | |
| return $true | |
| } | |
| } | |
| return $false | |
| } | |
| function Test-LocalCertificateChain { | |
| param( | |
| [Parameter(Mandatory = $true)] | |
| [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate | |
| ) | |
| $chain = New-Object System.Security.Cryptography.X509Certificates.X509Chain | |
| try { | |
| $chain.ChainPolicy.RevocationMode = [System.Security.Cryptography.X509Certificates.X509RevocationMode]::NoCheck | |
| $chain.ChainPolicy.RevocationFlag = [System.Security.Cryptography.X509Certificates.X509RevocationFlag]::ExcludeRoot | |
| $chain.ChainPolicy.VerificationFlags = [System.Security.Cryptography.X509Certificates.X509VerificationFlags]::NoFlag | |
| $valid = $chain.Build($Certificate) | |
| $statuses = @( | |
| $chain.ChainStatus | | |
| ForEach-Object { | |
| ('{0}: {1}' -f $_.Status, $_.StatusInformation.Trim()) | |
| } | |
| ) | |
| return [pscustomobject]@{ | |
| Valid = $valid | |
| Statuses = $statuses | |
| Elements = @($chain.ChainElements | ForEach-Object { $_.Certificate.Subject }) | |
| } | |
| } | |
| finally { | |
| $chain.Dispose() | |
| } | |
| } | |
| function Get-StoreCertificate { | |
| param([string]$Thumbprint) | |
| $normalized = $Thumbprint.Replace(' ', '').ToUpperInvariant() | |
| return Get-ChildItem -Path Cert:\LocalMachine\My | | |
| Where-Object { $_.Thumbprint.Replace(' ', '').ToUpperInvariant() -eq $normalized } | | |
| Select-Object -First 1 | |
| } | |
| function Get-AssignedExchangeServices { | |
| param($ExchangeCertificate) | |
| $serviceText = [string]$ExchangeCertificate.Services | |
| $assigned = New-Object System.Collections.Generic.List[string] | |
| foreach ($service in @('IIS', 'SMTP', 'POP', 'IMAP')) { | |
| if ($serviceText -match ('(?i)(?:^|[,\s]){0}(?:$|[,\s])' -f [regex]::Escape($service))) { | |
| $assigned.Add($service) | Out-Null | |
| } | |
| } | |
| return $assigned.ToArray() | |
| } | |
| function Get-BindingPorts { | |
| param($Connector) | |
| $ports = New-Object System.Collections.Generic.List[int] | |
| foreach ($binding in @($Connector.Bindings)) { | |
| if ($null -eq $binding) { | |
| continue | |
| } | |
| if ($binding.PSObject.Properties['Port']) { | |
| $ports.Add([int]$binding.Port) | Out-Null | |
| continue | |
| } | |
| $bindingText = $binding.ToString() | |
| if ($bindingText -match ':(\d+)$') { | |
| $ports.Add([int]$Matches[1]) | Out-Null | |
| } | |
| } | |
| return @($ports | Sort-Object -Unique) | |
| } | |
| function Test-StringEqual { | |
| param($Left, $Right) | |
| $leftText = if ($null -eq $Left) { '' } else { $Left.ToString().Trim() } | |
| $rightText = if ($null -eq $Right) { '' } else { $Right.ToString().Trim() } | |
| return $leftText.Equals($rightText, [System.StringComparison]::OrdinalIgnoreCase) | |
| } | |
| function Restart-ServiceSafely { | |
| param([string]$Name) | |
| $service = Get-Service -Name $Name -ErrorAction SilentlyContinue | |
| if ($null -eq $service) { | |
| Write-Log ("Service {0} is not installed; skipping restart." -f $Name) -Level DEBUG | |
| return | |
| } | |
| if ($service.Status -ne 'Running') { | |
| Write-Log ("Service {0} is {1}; skipping restart." -f $Name, $service.Status) -Level DEBUG | |
| return | |
| } | |
| try { | |
| Write-Log ("Restarting service {0}..." -f $Name) | |
| Restart-Service -Name $Name -Force -ErrorAction Stop | |
| $service.WaitForStatus([System.ServiceProcess.ServiceControllerStatus]::Running, [TimeSpan]::FromMinutes(2)) | |
| Write-Log ("Service {0} restarted successfully." -f $Name) -Level SUCCESS | |
| } | |
| catch { | |
| Add-WarningRecord ("Could not restart service {0}: {1}. A maintenance-window reboot may be required." -f $Name, $_.Exception.Message) | |
| } | |
| } | |
| function Save-RunReport { | |
| param( | |
| [string]$Status, | |
| [object]$SelectedCertificate, | |
| [object[]]$ReceiveConnectors, | |
| [object[]]$SendConnectors, | |
| [string]$FailureMessage | |
| ) | |
| $report = [ordered]@{ | |
| Started = $script:RunStarted | |
| Finished = Get-Date | |
| Status = $Status | |
| Server = $Server | |
| TargetFqdn = $TargetFqdn | |
| SelectedCertificate = $SelectedCertificate | |
| ReceiveConnectors = $ReceiveConnectors | |
| SendConnectors = $SendConnectors | |
| ChangesMade = $script:ChangesMade | |
| Actions = @($script:Actions) | |
| Warnings = @($script:Warnings) | |
| Failure = $FailureMessage | |
| LogFile = $script:LogFile | |
| } | |
| $report | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $reportFile -Encoding UTF8 | |
| } | |
| $mutexName = 'Global\ExchangeCertificateAutoUpdate-{0}' -f ($Server -replace '[^A-Za-z0-9_.-]', '_') | |
| $mutex = New-Object System.Threading.Mutex($false, $mutexName) | |
| $mutexAcquired = $false | |
| $selectedSummary = $null | |
| $selectedReceiveConnectors = @() | |
| $selectedSendConnectors = @() | |
| try { | |
| try { | |
| $mutexAcquired = $mutex.WaitOne(0) | |
| } | |
| catch [System.Threading.AbandonedMutexException] { | |
| $mutexAcquired = $true | |
| } | |
| if (-not $mutexAcquired) { | |
| Write-Log 'Another certificate update run is already active. Exiting.' -Level WARN | |
| Save-RunReport -Status 'SkippedConcurrentRun' -SelectedCertificate $null -ReceiveConnectors @() -SendConnectors @() -FailureMessage $null | |
| exit 0 | |
| } | |
| Write-Log ("Starting Exchange certificate update for server {0}, public name {1}." -f $Server, $TargetFqdn) | |
| Write-Log ("Requested Exchange services: {0}" -f ($Services -join ', ')) | |
| Write-Log ("Minimum remaining validity: {0} day(s)." -f $MinValidityDays) | |
| Import-ExchangeCommandEnvironment | |
| $now = Get-Date | |
| $minimumExpiry = $now.AddDays($MinValidityDays) | |
| $exchangeCertificates = @(Get-ExchangeCertificate -Server $Server) | |
| $candidates = New-Object System.Collections.Generic.List[object] | |
| foreach ($exchangeCertificate in $exchangeCertificates) { | |
| $thumbprint = [string]$exchangeCertificate.Thumbprint | |
| $storeCertificate = Get-StoreCertificate -Thumbprint $thumbprint | |
| if ($null -eq $storeCertificate) { | |
| Write-Log ("Skipping {0}: certificate is not present in LocalMachine\My." -f $thumbprint) -Level DEBUG | |
| continue | |
| } | |
| $certificateNames = Get-CertificateNames -ExchangeCertificate $exchangeCertificate | |
| $nameMatch = Test-DnsNameMatch -Names $certificateNames -DnsName $TargetFqdn | |
| $isSelfSigned = $storeCertificate.Subject -eq $storeCertificate.Issuer | |
| $hasServerAuth = Test-ServerAuthenticationEku -Certificate $storeCertificate | |
| $exchangeStatus = [string]$exchangeCertificate.Status | |
| if (-not $nameMatch) { | |
| continue | |
| } | |
| if ($isSelfSigned) { | |
| Write-Log ("Skipping {0}: matching certificate is self-signed." -f $thumbprint) -Level DEBUG | |
| continue | |
| } | |
| if (-not $storeCertificate.HasPrivateKey) { | |
| Write-Log ("Skipping {0}: private key is missing." -f $thumbprint) -Level WARN | |
| continue | |
| } | |
| if (-not $hasServerAuth) { | |
| Write-Log ("Skipping {0}: Server Authentication EKU is missing." -f $thumbprint) -Level WARN | |
| continue | |
| } | |
| if ($storeCertificate.NotBefore -gt $now) { | |
| Write-Log ("Skipping {0}: certificate is not valid until {1:o}." -f $thumbprint, $storeCertificate.NotBefore) -Level WARN | |
| continue | |
| } | |
| if ($storeCertificate.NotAfter -le $minimumExpiry) { | |
| Write-Log ("Skipping {0}: certificate expires at {1:o}, before the minimum-validity threshold." -f $thumbprint, $storeCertificate.NotAfter) -Level WARN | |
| continue | |
| } | |
| if ($exchangeStatus -and $exchangeStatus -notin @('Valid', 'Unknown')) { | |
| Write-Log ("Skipping {0}: Exchange reports status {1}." -f $thumbprint, $exchangeStatus) -Level WARN | |
| continue | |
| } | |
| $chainResult = Test-LocalCertificateChain -Certificate $storeCertificate | |
| if ($RequireTrustedChain -and -not $chainResult.Valid) { | |
| $statusText = if ($chainResult.Statuses.Count -gt 0) { $chainResult.Statuses -join '; ' } else { 'No chain status was returned.' } | |
| Write-Log ("Skipping {0}: local chain validation failed: {1}" -f $thumbprint, $statusText) -Level WARN | |
| continue | |
| } | |
| $candidates.Add([pscustomobject]@{ | |
| ExchangeCertificate = $exchangeCertificate | |
| StoreCertificate = $storeCertificate | |
| Thumbprint = $thumbprint | |
| Subject = $storeCertificate.Subject | |
| Issuer = $storeCertificate.Issuer | |
| NotBefore = $storeCertificate.NotBefore | |
| NotAfter = $storeCertificate.NotAfter | |
| CertificateNames = $certificateNames | |
| ChainValid = $chainResult.Valid | |
| ChainStatuses = $chainResult.Statuses | |
| }) | Out-Null | |
| } | |
| if ($candidates.Count -eq 0) { | |
| throw ("No usable CA-issued certificate matching {0} was found with at least {1} day(s) of remaining validity." -f $TargetFqdn, $MinValidityDays) | |
| } | |
| $selected = $candidates | | |
| Sort-Object ` | |
| @{ Expression = { $_.NotBefore }; Descending = $true }, ` | |
| @{ Expression = { $_.NotAfter }; Descending = $true } | | |
| Select-Object -First 1 | |
| $selectedSummary = [ordered]@{ | |
| Thumbprint = $selected.Thumbprint | |
| Subject = $selected.Subject | |
| Issuer = $selected.Issuer | |
| NotBefore = $selected.NotBefore | |
| NotAfter = $selected.NotAfter | |
| CertificateNames = $selected.CertificateNames | |
| ChainValid = $selected.ChainValid | |
| } | |
| Write-Log ("Selected certificate {0}: {1}; valid {2:o} through {3:o}." -f $selected.Thumbprint, $selected.Subject, $selected.NotBefore, $selected.NotAfter) -Level SUCCESS | |
| $tlsCertificateName = '<I>{0}<S>{1}' -f $selected.Issuer, $selected.Subject | |
| $currentlyAssignedServices = @(Get-AssignedExchangeServices -ExchangeCertificate $selected.ExchangeCertificate) | |
| $missingServices = @($Services | Where-Object { $_ -notin $currentlyAssignedServices }) | |
| if ($missingServices.Count -gt 0) { | |
| if (($missingServices -contains 'SMTP') -and -not $AllowReplaceInternalTransportCertificate) { | |
| throw ( | |
| "The selected certificate is not yet assigned to SMTP. For fully unattended assignment, rerun with " + | |
| "-AllowReplaceInternalTransportCertificate. Exchange may replace its default internal transport certificate when SMTP is forced." | |
| ) | |
| } | |
| $serviceDescription = $missingServices -join ', ' | |
| if ($PSCmdlet.ShouldProcess($selected.Thumbprint, "Assign Exchange services: $serviceDescription")) { | |
| Write-Log ("Assigning missing Exchange services to selected certificate: {0}." -f $serviceDescription) | |
| Enable-ExchangeCertificate -Server $Server -Thumbprint $selected.Thumbprint -Services $missingServices -Force | |
| Add-ActionRecord -Type 'ExchangeServices' -Target $selected.Thumbprint -Before ($currentlyAssignedServices -join ',') -After (($currentlyAssignedServices + $missingServices | Sort-Object -Unique) -join ',') -Result 'Updated' | |
| $script:ChangesMade = $true | |
| if ($missingServices -contains 'IIS') { $script:IisChanged = $true } | |
| if ($missingServices -contains 'SMTP') { $script:SmtpChanged = $true } | |
| if ($missingServices -contains 'POP') { $script:PopChanged = $true } | |
| if ($missingServices -contains 'IMAP') { $script:ImapChanged = $true } | |
| } | |
| } | |
| else { | |
| Write-Log 'The selected certificate is already assigned to all requested Exchange services.' | |
| } | |
| if ($Services -contains 'SMTP') { | |
| $allReceiveConnectors = @(Get-ReceiveConnector -Server $Server) | |
| if ($ReceiveConnectorNames.Count -gt 0) { | |
| $selectedReceiveConnectors = @( | |
| foreach ($name in $ReceiveConnectorNames) { | |
| $match = $allReceiveConnectors | | |
| Where-Object { $_.Name -ieq $name } | | |
| Select-Object -First 1 | |
| if ($null -eq $match) { | |
| Add-WarningRecord ("Receive connector not found on {0}: {1}" -f $Server, $name) | |
| } | |
| else { | |
| $match | |
| } | |
| } | |
| ) | |
| } | |
| else { | |
| $selectedReceiveConnectors = @( | |
| $allReceiveConnectors | | |
| Where-Object { | |
| $connector = $_ | |
| $ports = @(Get-BindingPorts -Connector $connector) | |
| $portMatches = @($ports | Where-Object { $_ -in $ReceivePorts }).Count -gt 0 | |
| $roleMatches = $true | |
| if (-not $IncludeNonFrontendReceiveConnectors -and $connector.PSObject.Properties['TransportRole']) { | |
| $roleMatches = ([string]$connector.TransportRole -eq 'FrontendTransport') | |
| } | |
| $portMatches -and $roleMatches | |
| } | |
| ) | |
| } | |
| if ($selectedReceiveConnectors.Count -eq 0) { | |
| Add-WarningRecord ("No Receive connectors were selected. Checked ports: {0}." -f ($ReceivePorts -join ', ')) | |
| } | |
| foreach ($connector in $selectedReceiveConnectors) { | |
| $currentTls = if ($null -eq $connector.TlsCertificateName) { '' } else { $connector.TlsCertificateName.ToString() } | |
| $currentFqdn = if ($null -eq $connector.Fqdn) { '' } else { $connector.Fqdn.ToString() } | |
| $setParameters = @{ | |
| Identity = $connector.Identity | |
| } | |
| $needsChange = $false | |
| if (-not (Test-StringEqual -Left $currentTls -Right $tlsCertificateName)) { | |
| $setParameters['TlsCertificateName'] = $tlsCertificateName | |
| $needsChange = $true | |
| } | |
| if (-not $DoNotUpdateConnectorFqdn -and -not (Test-StringEqual -Left $currentFqdn -Right $TargetFqdn)) { | |
| $setParameters['Fqdn'] = $TargetFqdn | |
| $needsChange = $true | |
| } | |
| if ($needsChange) { | |
| if ($PSCmdlet.ShouldProcess($connector.Identity.ToString(), "Update Receive connector TLS certificate/FQDN")) { | |
| Write-Log ("Updating Receive connector {0}. Ports: {1}; FQDN: {2} -> {3}." -f $connector.Name, ((Get-BindingPorts -Connector $connector) -join ','), $currentFqdn, $(if ($DoNotUpdateConnectorFqdn) { $currentFqdn } else { $TargetFqdn })) | |
| Set-ReceiveConnector @setParameters | |
| Add-ActionRecord -Type 'ReceiveConnector' -Target $connector.Identity.ToString() -Before ("FQDN={0}; TLS={1}" -f $currentFqdn, $currentTls) -After ("FQDN={0}; TLS={1}" -f $(if ($DoNotUpdateConnectorFqdn) { $currentFqdn } else { $TargetFqdn }), $tlsCertificateName) -Result 'Updated' | |
| $script:ChangesMade = $true | |
| $script:SmtpChanged = $true | |
| } | |
| } | |
| else { | |
| Write-Log ("Receive connector {0} is already correct." -f $connector.Name) | |
| } | |
| } | |
| $allSendConnectors = @(Get-SendConnector) | |
| if ($SendConnectorNames.Count -gt 0) { | |
| $selectedSendConnectors = @( | |
| foreach ($name in $SendConnectorNames) { | |
| $match = $allSendConnectors | | |
| Where-Object { $_.Name -ieq $name } | | |
| Select-Object -First 1 | |
| if ($null -eq $match) { | |
| Add-WarningRecord ("Send connector not found: {0}" -f $name) | |
| } | |
| else { | |
| $match | |
| } | |
| } | |
| ) | |
| } | |
| else { | |
| $selectedSendConnectors = @( | |
| $allSendConnectors | | |
| Where-Object { | |
| $fqdn = if ($null -eq $_.Fqdn) { '' } else { $_.Fqdn.ToString() } | |
| $fqdn -ieq $TargetFqdn | |
| } | |
| ) | |
| } | |
| if ($selectedSendConnectors.Count -eq 0) { | |
| Add-WarningRecord ("No Send connectors were selected. By default, only connectors whose Fqdn equals {0} are selected." -f $TargetFqdn) | |
| } | |
| foreach ($connector in $selectedSendConnectors) { | |
| $currentTls = if ($null -eq $connector.TlsCertificateName) { '' } else { $connector.TlsCertificateName.ToString() } | |
| $currentFqdn = if ($null -eq $connector.Fqdn) { '' } else { $connector.Fqdn.ToString() } | |
| $setParameters = @{ | |
| Identity = $connector.Identity | |
| } | |
| $needsChange = $false | |
| if (-not (Test-StringEqual -Left $currentTls -Right $tlsCertificateName)) { | |
| $setParameters['TlsCertificateName'] = $tlsCertificateName | |
| $needsChange = $true | |
| } | |
| if (-not $DoNotUpdateConnectorFqdn -and -not (Test-StringEqual -Left $currentFqdn -Right $TargetFqdn)) { | |
| $setParameters['Fqdn'] = $TargetFqdn | |
| $needsChange = $true | |
| } | |
| if ($needsChange) { | |
| if ($PSCmdlet.ShouldProcess($connector.Identity.ToString(), "Update Send connector TLS certificate/FQDN")) { | |
| Write-Log ("Updating Send connector {0}. FQDN: {1} -> {2}." -f $connector.Name, $currentFqdn, $(if ($DoNotUpdateConnectorFqdn) { $currentFqdn } else { $TargetFqdn })) | |
| Set-SendConnector @setParameters | |
| Add-ActionRecord -Type 'SendConnector' -Target $connector.Identity.ToString() -Before ("FQDN={0}; TLS={1}" -f $currentFqdn, $currentTls) -After ("FQDN={0}; TLS={1}" -f $(if ($DoNotUpdateConnectorFqdn) { $currentFqdn } else { $TargetFqdn }), $tlsCertificateName) -Result 'Updated' | |
| $script:ChangesMade = $true | |
| $script:SmtpChanged = $true | |
| } | |
| } | |
| else { | |
| Write-Log ("Send connector {0} is already correct." -f $connector.Name) | |
| } | |
| } | |
| } | |
| if ($RemoveExpiredMatchingCertificates -or $RemoveSupersededMatchingCertificates) { | |
| $cleanupCandidates = @( | |
| $candidates | | |
| Where-Object { $_.Thumbprint -ne $selected.Thumbprint } | | |
| Sort-Object NotBefore -Descending | |
| ) | |
| if ($RemoveSupersededMatchingCertificates -and $cleanupCandidates.Count -gt 0) { | |
| $cleanupCandidates = @($cleanupCandidates | Select-Object -Skip $KeepPreviousCertificates) | |
| } | |
| elseif (-not $RemoveSupersededMatchingCertificates) { | |
| $cleanupCandidates = @() | |
| } | |
| if ($RemoveExpiredMatchingCertificates) { | |
| $expiredMatching = @( | |
| foreach ($exchangeCertificate in $exchangeCertificates) { | |
| if ([string]$exchangeCertificate.Thumbprint -eq $selected.Thumbprint) { | |
| continue | |
| } | |
| $storeCertificate = Get-StoreCertificate -Thumbprint ([string]$exchangeCertificate.Thumbprint) | |
| if ($null -eq $storeCertificate) { | |
| continue | |
| } | |
| $names = Get-CertificateNames -ExchangeCertificate $exchangeCertificate | |
| $matchesName = Test-DnsNameMatch -Names $names -DnsName $TargetFqdn | |
| $selfSigned = $storeCertificate.Subject -eq $storeCertificate.Issuer | |
| if ($matchesName -and -not $selfSigned -and $storeCertificate.NotAfter -le $now) { | |
| [pscustomobject]@{ | |
| Thumbprint = [string]$exchangeCertificate.Thumbprint | |
| Subject = $storeCertificate.Subject | |
| Issuer = $storeCertificate.Issuer | |
| NotBefore = $storeCertificate.NotBefore | |
| NotAfter = $storeCertificate.NotAfter | |
| } | |
| } | |
| } | |
| ) | |
| $cleanupCandidates = @($cleanupCandidates + $expiredMatching | Sort-Object Thumbprint -Unique) | |
| } | |
| foreach ($oldCertificate in $cleanupCandidates) { | |
| if ($RemoveSupersededMatchingCertificates) { | |
| $cleanupAllowedAt = $selected.NotBefore.AddDays($CleanupGraceDays) | |
| if ((Get-Date) -lt $cleanupAllowedAt -and $oldCertificate.NotAfter -gt $now) { | |
| Write-Log ("Keeping superseded certificate {0} until cleanup grace date {1:o}." -f $oldCertificate.Thumbprint, $cleanupAllowedAt) | |
| continue | |
| } | |
| } | |
| if ($PSCmdlet.ShouldProcess($oldCertificate.Thumbprint, "Remove old matching Exchange certificate")) { | |
| try { | |
| Write-Log ("Removing old matching certificate {0}, expired {1:o}." -f $oldCertificate.Thumbprint, $oldCertificate.NotAfter) | |
| Remove-ExchangeCertificate -Server $Server -Thumbprint $oldCertificate.Thumbprint -Confirm:$false | |
| Add-ActionRecord -Type 'RemoveCertificate' -Target $oldCertificate.Thumbprint -Before $oldCertificate.Subject -After '' -Result 'Removed' | |
| $script:ChangesMade = $true | |
| } | |
| catch { | |
| Add-WarningRecord ("Could not remove certificate {0}: {1}. It may still be referenced by a connector or Exchange service." -f $oldCertificate.Thumbprint, $_.Exception.Message) | |
| } | |
| } | |
| } | |
| } | |
| if ($RestartAffectedServices -and $script:ChangesMade -and -not $WhatIfPreference) { | |
| if ($script:IisChanged) { | |
| try { | |
| Write-Log 'Restarting IIS with iisreset /noforce...' | |
| & "$env:windir\System32\iisreset.exe" /noforce | ForEach-Object { Write-Log $_ -Level DEBUG } | |
| if ($LASTEXITCODE -ne 0) { | |
| Add-WarningRecord ("iisreset returned exit code {0}." -f $LASTEXITCODE) | |
| } | |
| else { | |
| Write-Log 'IIS restarted successfully.' -Level SUCCESS | |
| } | |
| } | |
| catch { | |
| Add-WarningRecord ("Could not restart IIS: {0}" -f $_.Exception.Message) | |
| } | |
| } | |
| if ($script:SmtpChanged) { | |
| Restart-ServiceSafely -Name 'MSExchangeFrontEndTransport' | |
| Restart-ServiceSafely -Name 'MSExchangeTransport' | |
| } | |
| if ($script:PopChanged) { | |
| Restart-ServiceSafely -Name 'MSExchangePOP3' | |
| Restart-ServiceSafely -Name 'MSExchangePOP3BE' | |
| } | |
| if ($script:ImapChanged) { | |
| Restart-ServiceSafely -Name 'MSExchangeIMAP4' | |
| Restart-ServiceSafely -Name 'MSExchangeIMAP4BE' | |
| } | |
| } | |
| if (-not $WhatIfPreference) { | |
| $refreshedCertificate = Get-ExchangeCertificate -Server $Server -Thumbprint $selected.Thumbprint | |
| $refreshedServices = @(Get-AssignedExchangeServices -ExchangeCertificate $refreshedCertificate) | |
| $missingAfterUpdate = @($Services | Where-Object { $_ -notin $refreshedServices }) | |
| if ($missingAfterUpdate.Count -gt 0) { | |
| throw ("Verification failed: selected certificate is still missing Exchange services: {0}" -f ($missingAfterUpdate -join ', ')) | |
| } | |
| foreach ($connector in $selectedReceiveConnectors) { | |
| $refreshed = Get-ReceiveConnector -Identity $connector.Identity | |
| $actualTls = if ($null -eq $refreshed.TlsCertificateName) { '' } else { $refreshed.TlsCertificateName.ToString() } | |
| if (-not (Test-StringEqual -Left $actualTls -Right $tlsCertificateName)) { | |
| throw ("Verification failed for Receive connector {0}: TlsCertificateName does not match selected certificate." -f $connector.Name) | |
| } | |
| if (-not $DoNotUpdateConnectorFqdn -and -not (Test-StringEqual -Left $refreshed.Fqdn -Right $TargetFqdn)) { | |
| throw ("Verification failed for Receive connector {0}: Fqdn is not {1}." -f $connector.Name, $TargetFqdn) | |
| } | |
| } | |
| foreach ($connector in $selectedSendConnectors) { | |
| $refreshed = Get-SendConnector -Identity $connector.Identity | |
| $actualTls = if ($null -eq $refreshed.TlsCertificateName) { '' } else { $refreshed.TlsCertificateName.ToString() } | |
| if (-not (Test-StringEqual -Left $actualTls -Right $tlsCertificateName)) { | |
| throw ("Verification failed for Send connector {0}: TlsCertificateName does not match selected certificate." -f $connector.Name) | |
| } | |
| if (-not $DoNotUpdateConnectorFqdn -and -not (Test-StringEqual -Left $refreshed.Fqdn -Right $TargetFqdn)) { | |
| throw ("Verification failed for Send connector {0}: Fqdn is not {1}." -f $connector.Name, $TargetFqdn) | |
| } | |
| } | |
| $state = [ordered]@{ | |
| LastSuccessfulRun = Get-Date | |
| Server = $Server | |
| TargetFqdn = $TargetFqdn | |
| Thumbprint = $selected.Thumbprint | |
| Subject = $selected.Subject | |
| Issuer = $selected.Issuer | |
| NotBefore = $selected.NotBefore | |
| NotAfter = $selected.NotAfter | |
| TlsCertificateName = $tlsCertificateName | |
| Services = $Services | |
| ReceiveConnectors = @($selectedReceiveConnectors | ForEach-Object { $_.Identity.ToString() }) | |
| SendConnectors = @($selectedSendConnectors | ForEach-Object { $_.Identity.ToString() }) | |
| } | |
| $state | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $stateFile -Encoding UTF8 | |
| } | |
| if ($script:ChangesMade) { | |
| Write-Log 'Exchange certificate update completed successfully with changes.' -Level SUCCESS | |
| } | |
| else { | |
| Write-Log 'Exchange certificate configuration is already current; no changes were required.' -Level SUCCESS | |
| } | |
| Save-RunReport -Status 'Success' -SelectedCertificate $selectedSummary -ReceiveConnectors @($selectedReceiveConnectors | ForEach-Object { $_.Identity.ToString() }) -SendConnectors @($selectedSendConnectors | ForEach-Object { $_.Identity.ToString() }) -FailureMessage $null | |
| Write-Log ("Report written to {0}" -f $reportFile) | |
| exit 0 | |
| } | |
| catch { | |
| $failure = $_.Exception.Message | |
| Write-Log -Message $failure -Level ERROR | |
| Save-RunReport -Status 'Failed' -SelectedCertificate $selectedSummary -ReceiveConnectors @($selectedReceiveConnectors | ForEach-Object { $_.Identity.ToString() }) -SendConnectors @($selectedSendConnectors | ForEach-Object { $_.Identity.ToString() }) -FailureMessage $failure | |
| Write-Log ("Failure report written to {0}" -f $reportFile) -Level ERROR | |
| exit 1 | |
| } | |
| finally { | |
| if ($mutexAcquired) { | |
| try { | |
| $mutex.ReleaseMutex() | |
| } | |
| catch { | |
| # Ignore release errors during process teardown. | |
| } | |
| } | |
| $mutex.Dispose() | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment