Public/iis/Set-IISBindingCertificate.ps1

#Requires -Version 5.1
function Set-IISBindingCertificate {
    <#
        .SYNOPSIS
            Replaces the SSL/TLS certificate on one or more IIS https site bindings.
 
        .DESCRIPTION
            Replace the SSL/TLS certificate bound to one or more IIS HTTPS site bindings,
            typically to rotate a certificate that is approaching expiration. The new
            certificate must already exist in the target certificate store (LocalMachine\My
            by default). The function is idempotent: running it twice with the same
            thumbprint yields Status=AlreadyUpToDate on the second call. Supports remote
            execution via WinRM, -WhatIf/-Confirm (ConfirmImpact=High), and pipeline input
            by property name from Get-IISHealth / Get-SSLCertificate.
 
        .PARAMETER ComputerName
            One or more computer names to target. Defaults to the local machine.
            Accepts pipeline input by value and by property name.
 
        .PARAMETER Credential
            Optional PSCredential for authenticating to remote computers.
            Not used for local queries.
 
        .PARAMETER SiteName
            IIS site name (Get-Website -Name).
 
        .PARAMETER BindingInformation
            Binding selector ip:port:hostheader (e.g. *:443:www.contoso.com).
            When omitted, the function applies to ALL https bindings of the site.
 
        .PARAMETER Thumbprint
            SHA-1 thumbprint (40 hex chars) of the new certificate.
            Must already be present in -CertStoreLocation on the target.
 
        .PARAMETER CertStoreLocation
            Certificate store to read the new cert from.
            Valid values: 'Cert:\LocalMachine\My' (default) or 'Cert:\LocalMachine\WebHosting'.
 
        .PARAMETER Force
            Bypass the ConfirmImpact=High prompt (equivalent to -Confirm:$false).
 
        .EXAMPLE
            Set-IISBindingCertificate -SiteName 'www.contoso.com' -Thumbprint 'A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2' -Confirm:$false
 
            Replaces the cert on every https binding of the site without prompting.
 
        .EXAMPLE
            Set-IISBindingCertificate -SiteName 'Default Web Site' -BindingInformation '*:443:portal.contoso.com' -Thumbprint $newTp
 
            Targets one specific binding by its ip:port:hostheader selector.
 
        .EXAMPLE
            'WEB01','WEB02','WEB03' | Set-IISBindingCertificate -SiteName 'api' -Thumbprint $newTp -Credential (Get-Credential) -WhatIf
 
            Previews certificate rotation across a fleet via pipeline with explicit credentials.
 
        .EXAMPLE
            Get-SSLCertificate -ComputerName WEB01 -Port 443 | Set-IISBindingCertificate -SiteName 'www' -Thumbprint $newTp
 
            Pipeline-by-property-name from Get-SSLCertificate.
 
        .OUTPUTS
            PSCustomObject (PSTypeName='PSWinOps.IISBindingCertificateResult')
            Returns one object per (ComputerName, binding) pair.
 
        .NOTES
            Author: Franck SALLET
            Version: 1.0.0
            Last Modified: 2026-05-14
            Requires: PowerShell 5.1+ / Windows only
            Requires: Web-Server (IIS) role
            Requires: Module WebAdministration or IISAdministration
 
        .LINK
            https://github.com/k9fr4n/PSWinOps
 
        .LINK
            https://learn.microsoft.com/en-us/iis/manage/powershell/powershell-snap-in-changing-simple-settings-at-the-command-line
    #>

    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
    [OutputType('PSWinOps.IISBindingCertificateResult')]
    param(
        [Parameter(Mandatory = $false, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [ValidateNotNullOrEmpty()]
        [Alias('CN', 'Name', 'DNSHostName')]
        [string[]]$ComputerName = $env:COMPUTERNAME,

        [Parameter(Mandatory = $false)]
        [ValidateNotNull()]
        [System.Management.Automation.PSCredential]
        [System.Management.Automation.Credential()]
        $Credential,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$SiteName,

        [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)]
        [string]$BindingInformation,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [ValidatePattern('^[A-Fa-f0-9]{40}$')]
        [string]$Thumbprint,

        [Parameter(Mandatory = $false)]
        [ValidateSet('Cert:\LocalMachine\My', 'Cert:\LocalMachine\WebHosting')]
        [string]$CertStoreLocation = 'Cert:\LocalMachine\My',

        [Parameter(Mandatory = $false)]
        [switch]$Force
    )

    begin {
        Write-Verbose -Message "[$($MyInvocation.MyCommand)] Starting"

        # Honour -Force by suppressing the ConfirmImpact=High prompt.
        if ($Force.IsPresent) {
            $ConfirmPreference = 'None'
        }

        # ---------------------------------------------------------------
        # Phase 1 scriptBlock — read-only state query (safe under -WhatIf)
        # ---------------------------------------------------------------
        $queryScriptBlock = {
            param(
                [string]$QSiteName,
                [string]$QBindingInformation,
                [string]$QThumbprint,
                [string]$QCertStoreLocation
            )

            $queryResults = [System.Collections.Generic.List[hashtable]]::new()

            # Detect available IIS module (mirrors Get-IISHealth pattern).
            $iisModule = $null
            if (Get-Module -Name 'WebAdministration' -ListAvailable -ErrorAction SilentlyContinue) {
                $iisModule = 'WebAdministration'
            }
            elseif (Get-Module -Name 'IISAdministration' -ListAvailable -ErrorAction SilentlyContinue) {
                $iisModule = 'IISAdministration'
            }

            if ($null -eq $iisModule) {
                $queryResults.Add(@{
                    SiteName           = $QSiteName
                    BindingInformation = $QBindingInformation
                    Protocol           = 'https'
                    PreviousThumbprint = $null
                    NewThumbprint      = $QThumbprint
                    CertStoreLocation  = $QCertStoreLocation
                    SslFlags           = 0
                    Status             = 'Failed'
                    ErrorMessage       = 'Neither WebAdministration nor IISAdministration module is available on the target.'
                    SslKey             = $null
                })
                return $queryResults
            }

            try {
                Import-Module -Name $iisModule -ErrorAction Stop

                if ($iisModule -eq 'WebAdministration') {

                    $site = Get-Website -Name $QSiteName -ErrorAction SilentlyContinue
                    if ($null -eq $site) {
                        $queryResults.Add(@{
                            SiteName           = $QSiteName
                            BindingInformation = $QBindingInformation
                            Protocol           = 'https'
                            PreviousThumbprint = $null
                            NewThumbprint      = $QThumbprint
                            CertStoreLocation  = $QCertStoreLocation
                            SslFlags           = 0
                            Status             = 'BindingNotFound'
                            ErrorMessage       = "Site '$QSiteName' not found."
                            SslKey             = $null
                        })
                        return $queryResults
                    }

                    $httpsBindings = @(Get-WebBinding -Name $QSiteName -Protocol 'https' -ErrorAction SilentlyContinue)
                    if ($httpsBindings.Count -eq 0) {
                        $queryResults.Add(@{
                            SiteName           = $QSiteName
                            BindingInformation = $QBindingInformation
                            Protocol           = 'https'
                            PreviousThumbprint = $null
                            NewThumbprint      = $QThumbprint
                            CertStoreLocation  = $QCertStoreLocation
                            SslFlags           = 0
                            Status             = 'BindingNotFound'
                            ErrorMessage       = "No https bindings found on site '$QSiteName'."
                            SslKey             = $null
                        })
                        return $queryResults
                    }

                    if (-not [string]::IsNullOrEmpty($QBindingInformation)) {
                        $httpsBindings = @($httpsBindings | Where-Object { $_.bindingInformation -eq $QBindingInformation })
                        if ($httpsBindings.Count -eq 0) {
                            $queryResults.Add(@{
                                SiteName           = $QSiteName
                                BindingInformation = $QBindingInformation
                                Protocol           = 'https'
                                PreviousThumbprint = $null
                                NewThumbprint      = $QThumbprint
                                CertStoreLocation  = $QCertStoreLocation
                                SslFlags           = 0
                                Status             = 'BindingNotFound'
                                ErrorMessage       = "No https binding matching '$QBindingInformation' found on site '$QSiteName'."
                                SslKey             = $null
                            })
                            return $queryResults
                        }
                    }

                    foreach ($wb in $httpsBindings) {
                        $bindInfo    = $wb.bindingInformation
                        $sslFlagsVal = [int]$wb.sslFlags

                        # Build IIS:\SslBindings key: <ip>!<port> or <ip>!<port>!<host>
                        $parts      = $bindInfo -split ':'
                        $ipPart     = $parts[0]
                        $portPart   = $parts[1]
                        $hostHeader = if ($parts.Count -ge 3) { $parts[2] } else { '' }
                        $sslKey     = if ([string]::IsNullOrEmpty($hostHeader)) {
                            "$ipPart!$portPart"
                        } else {
                            "$ipPart!$portPart!$hostHeader"
                        }

                        $prevThumbprint = $null
                        try {
                            $existingEntry  = Get-Item -Path "IIS:\SslBindings\$sslKey" -ErrorAction Stop
                            $prevThumbprint = $existingEntry.Thumbprint
                        }
                        catch {
                            Write-Verbose -Message "No pre-existing SSL binding found for key '$sslKey'."
                        }

                        $certPath  = Join-Path -Path $QCertStoreLocation -ChildPath $QThumbprint
                        $certFound = Test-Path -Path $certPath

                        $bindingStatus = if ($prevThumbprint -eq $QThumbprint) {
                            'AlreadyUpToDate'
                        }
                        elseif (-not $certFound) {
                            'CertNotFound'
                        }
                        else {
                            'NeedsReplacement'
                        }

                        $queryResults.Add(@{
                            SiteName           = $QSiteName
                            BindingInformation = $bindInfo
                            Protocol           = 'https'
                            PreviousThumbprint = $prevThumbprint
                            NewThumbprint      = $QThumbprint
                            CertStoreLocation  = $QCertStoreLocation
                            SslFlags           = $sslFlagsVal
                            Status             = $bindingStatus
                            ErrorMessage       = $null
                            SslKey             = $sslKey
                        })
                    }
                }
                else {
                    # IISAdministration fallback
                    $site = Get-IISSite -Name $QSiteName -ErrorAction SilentlyContinue
                    if ($null -eq $site) {
                        $queryResults.Add(@{
                            SiteName           = $QSiteName
                            BindingInformation = $QBindingInformation
                            Protocol           = 'https'
                            PreviousThumbprint = $null
                            NewThumbprint      = $QThumbprint
                            CertStoreLocation  = $QCertStoreLocation
                            SslFlags           = 0
                            Status             = 'BindingNotFound'
                            ErrorMessage       = "Site '$QSiteName' not found."
                            SslKey             = $null
                        })
                        return $queryResults
                    }

                    $httpsBindings = @($site.Bindings | Where-Object { $_.Protocol -eq 'https' })
                    if ($httpsBindings.Count -eq 0) {
                        $queryResults.Add(@{
                            SiteName           = $QSiteName
                            BindingInformation = $QBindingInformation
                            Protocol           = 'https'
                            PreviousThumbprint = $null
                            NewThumbprint      = $QThumbprint
                            CertStoreLocation  = $QCertStoreLocation
                            SslFlags           = 0
                            Status             = 'BindingNotFound'
                            ErrorMessage       = "No https bindings found on site '$QSiteName'."
                            SslKey             = $null
                        })
                        return $queryResults
                    }

                    if (-not [string]::IsNullOrEmpty($QBindingInformation)) {
                        $httpsBindings = @($httpsBindings | Where-Object { $_.BindingInformation -eq $QBindingInformation })
                        if ($httpsBindings.Count -eq 0) {
                            $queryResults.Add(@{
                                SiteName           = $QSiteName
                                BindingInformation = $QBindingInformation
                                Protocol           = 'https'
                                PreviousThumbprint = $null
                                NewThumbprint      = $QThumbprint
                                CertStoreLocation  = $QCertStoreLocation
                                SslFlags           = 0
                                Status             = 'BindingNotFound'
                                ErrorMessage       = "No https binding matching '$QBindingInformation' found on site '$QSiteName'."
                                SslKey             = $null
                            })
                            return $queryResults
                        }
                    }

                    foreach ($ib in $httpsBindings) {
                        $bindInfo    = $ib.BindingInformation
                        $sslFlagsVal = [int]$ib.SslFlags

                        $parts      = $bindInfo -split ':'
                        $ipPart     = $parts[0]
                        $portPart   = $parts[1]
                        $hostHeader = if ($parts.Count -ge 3) { $parts[2] } else { '' }
                        $sslKey     = if ([string]::IsNullOrEmpty($hostHeader)) {
                            "$ipPart!$portPart"
                        } else {
                            "$ipPart!$portPart!$hostHeader"
                        }

                        # IISAdministration: CertificateHash is a byte array.
                        $prevThumbprint = $null
                        $certHashBytes  = $ib.CertificateHash
                        if ($certHashBytes) {
                            $prevThumbprint = ([System.BitConverter]::ToString([byte[]]$certHashBytes) -replace '-', '')
                        }

                        $certPath  = Join-Path -Path $QCertStoreLocation -ChildPath $QThumbprint
                        $certFound = Test-Path -Path $certPath

                        $bindingStatus = if ($prevThumbprint -eq $QThumbprint) {
                            'AlreadyUpToDate'
                        }
                        elseif (-not $certFound) {
                            'CertNotFound'
                        }
                        else {
                            'NeedsReplacement'
                        }

                        $queryResults.Add(@{
                            SiteName           = $QSiteName
                            BindingInformation = $bindInfo
                            Protocol           = 'https'
                            PreviousThumbprint = $prevThumbprint
                            NewThumbprint      = $QThumbprint
                            CertStoreLocation  = $QCertStoreLocation
                            SslFlags           = $sslFlagsVal
                            Status             = $bindingStatus
                            ErrorMessage       = $null
                            SslKey             = $sslKey
                        })
                    }
                }
            }
            catch {
                $queryResults.Add(@{
                    SiteName           = $QSiteName
                    BindingInformation = $QBindingInformation
                    Protocol           = 'https'
                    PreviousThumbprint = $null
                    NewThumbprint      = $QThumbprint
                    CertStoreLocation  = $QCertStoreLocation
                    SslFlags           = 0
                    Status             = 'Failed'
                    ErrorMessage       = $_.Exception.Message
                    SslKey             = $null
                })
            }

            return $queryResults
        }

        # ---------------------------------------------------------------
        # Phase 2 scriptBlock — write (cert replacement)
        # ---------------------------------------------------------------
        $applyScriptBlock = {
            param(
                [string]$ASiteName,
                [string]$ABindingInformation,
                [string]$AThumbprint,
                [string]$ACertStoreLocation,
                [string]$ASslKey,
                [int]$ASslFlags
            )

            # Re-detect available IIS module (cheap; avoids serializing module name).
            $iisModule = $null
            if (Get-Module -Name 'WebAdministration' -ListAvailable -ErrorAction SilentlyContinue) {
                $iisModule = 'WebAdministration'
            }
            elseif (Get-Module -Name 'IISAdministration' -ListAvailable -ErrorAction SilentlyContinue) {
                $iisModule = 'IISAdministration'
            }

            if ($null -eq $iisModule) {
                return @{ Success = $false; NewThumbprint = $null; ErrorMessage = 'IIS module unavailable on target.' }
            }

            try {
                Import-Module -Name $iisModule -ErrorAction Stop

                if ($iisModule -eq 'WebAdministration') {
                    # Remove existing SSL binding entry.
                    if (Test-Path -Path "IIS:\SslBindings\$ASslKey") {
                        Remove-Item -Path "IIS:\SslBindings\$ASslKey" -ErrorAction Stop
                    }

                    # Bind the new certificate (pipe cert object to New-Item on IIS: provider).
                    $certItem = Get-Item -Path (Join-Path -Path $ACertStoreLocation -ChildPath $AThumbprint) -ErrorAction Stop
                    $null = $certItem | New-Item -Path "IIS:\SslBindings\$ASslKey" -SslFlags $ASslFlags -ErrorAction Stop

                    # Verify replacement.
                    $confirmedEntry = Get-Item -Path "IIS:\SslBindings\$ASslKey" -ErrorAction Stop
                    return @{ Success = $true; NewThumbprint = $confirmedEntry.Thumbprint; ErrorMessage = $null }
                }
                else {
                    # IISAdministration fallback: remove and re-add the binding.
                    Remove-IISSiteBinding -Name $ASiteName -BindingInformation $ABindingInformation `
                        -Protocol 'https' -Confirm:$false -ErrorAction Stop

                    $addParams = @{
                        Name                  = $ASiteName
                        BindingInformation    = $ABindingInformation
                        Protocol              = 'https'
                        CertificateThumbPrint = $AThumbprint
                        CertStoreLocation     = $ACertStoreLocation
                        SslFlag               = $ASslFlags
                    }
                    Add-IISSiteBinding @addParams -ErrorAction Stop
                    return @{ Success = $true; NewThumbprint = $AThumbprint; ErrorMessage = $null }
                }
            }
            catch {
                return @{ Success = $false; NewThumbprint = $null; ErrorMessage = $_.Exception.Message }
            }
        }
    }

    process {
        foreach ($cn in $ComputerName) {
            Write-Verbose -Message "[$($MyInvocation.MyCommand)] Processing '$cn'"

            try {
                $queryArgs      = @($SiteName, $BindingInformation, $Thumbprint, $CertStoreLocation)
                $queryRawResult = Invoke-RemoteOrLocal -ComputerName $cn -Credential $Credential `
                    -ScriptBlock $queryScriptBlock -ArgumentList $queryArgs

                foreach ($entry in $queryRawResult) {
                    $entryStatus    = $entry.Status
                    $entryBindInfo  = $entry.BindingInformation
                    $entryPrevThumb = $entry.PreviousThumbprint
                    $entryNewThumb  = $entry.NewThumbprint
                    $entrySslFlags  = $entry.SslFlags
                    $entrySslKey    = $entry.SslKey
                    $entryErrMsg    = $entry.ErrorMessage

                    if ($entryStatus -eq 'NeedsReplacement') {
                        $spTarget = "$cn/$SiteName binding $entryBindInfo"
                        $spAction = "Replace SSL certificate $entryPrevThumb -> $Thumbprint"

                        if ($PSCmdlet.ShouldProcess($spTarget, $spAction)) {
                            try {
                                $applyArgs   = @($SiteName, $entryBindInfo, $Thumbprint, $CertStoreLocation, $entrySslKey, $entrySslFlags)
                                $applyResult = Invoke-RemoteOrLocal -ComputerName $cn -Credential $Credential `
                                    -ScriptBlock $applyScriptBlock -ArgumentList $applyArgs

                                if ($applyResult.Success) {
                                    $entryStatus   = 'Replaced'
                                    $entryNewThumb = $applyResult.NewThumbprint
                                    $entryErrMsg   = $null
                                }
                                else {
                                    $entryStatus = 'Failed'
                                    $entryErrMsg = $applyResult.ErrorMessage
                                }
                            }
                            catch {
                                $entryStatus = 'Failed'
                                $entryErrMsg = $_.Exception.Message
                            }

                            [PSCustomObject]@{
                                PSTypeName         = 'PSWinOps.IISBindingCertificateResult'
                                ComputerName       = $cn.ToUpper()
                                SiteName           = $entry.SiteName
                                BindingInformation = $entryBindInfo
                                Protocol           = $entry.Protocol
                                PreviousThumbprint = $entryPrevThumb
                                NewThumbprint      = $entryNewThumb
                                CertStoreLocation  = $entry.CertStoreLocation
                                SslFlags           = $entrySslFlags
                                Status             = $entryStatus
                                ErrorMessage       = $entryErrMsg
                                Timestamp          = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
                            }
                        }
                        # ShouldProcess returned $false (-WhatIf / user declined): no output emitted.
                    }
                    else {
                        # AlreadyUpToDate, CertNotFound, BindingNotFound, Failed: emit directly.
                        [PSCustomObject]@{
                            PSTypeName         = 'PSWinOps.IISBindingCertificateResult'
                            ComputerName       = $cn.ToUpper()
                            SiteName           = $entry.SiteName
                            BindingInformation = $entryBindInfo
                            Protocol           = $entry.Protocol
                            PreviousThumbprint = $entryPrevThumb
                            NewThumbprint      = $entryNewThumb
                            CertStoreLocation  = $entry.CertStoreLocation
                            SslFlags           = $entrySslFlags
                            Status             = $entryStatus
                            ErrorMessage       = $entryErrMsg
                            Timestamp          = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
                        }
                    }
                }
            }
            catch {
                Write-Error -Message "[$($MyInvocation.MyCommand)] Failed on '${cn}': $_"
                continue
            }
        }
    }

    end {
        Write-Verbose -Message "[$($MyInvocation.MyCommand)] Completed"
    }
}