Skip to content

Instantly share code, notes, and snippets.

@supermarsx
Created July 13, 2026 14:18
Show Gist options
  • Select an option

  • Save supermarsx/eadc06905ac9a648b738808ad2e7bb1b to your computer and use it in GitHub Desktop.

Select an option

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
#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