Public/Initialize-NewACMECertificate.ps1
function Initialize-NewACMECertificate{ [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Currently using Write-Host because it supports -NoNewLine')] param( [Parameter(Mandatory=$true, Position=1, HelpMessage="Comma-separated list of hostnames to include in the certificate. The first in the list = the main domain; remaining domains = SANs" )] [string[]] $DomainList, [Parameter(Mandatory = $false, HelpMessage = "Specify the friendly name to assign to the certificate" )] $FriendlyName = "$(($DomainList[0] -split "\.")[0]) (Posh-ACME $(get-date -format "MM-dd-yyyy"))", [Parameter(Mandatory=$false, HelpMessage="When applied, this switch will attempt to update IIS HTTPS bindings" )] [switch] $UpdateBindings, [Parameter(Mandatory=$false, HelpMessage="Comma-separated list of posts on which to update bindings. When this parameter is omitted, all HTTPS-based bindings will be updated" )] [string[]] $BindingPorts, [Parameter(Mandatory=$false, HelpMessage="When applied, this switch will skip the import of the resulting certificate into the Windows Certificate Store" )] [switch] $SkipImport, [Parameter(Mandatory=$false, HelpMessage="Specifies the Windows Certificate Store Name to import the resulting certificate into. When omitted, this parameter defaults to WebHosting" )] [ValidateScript({if($_ -in $VALIDATE_SET_CERTIFICATE_STORE_NAME) { $true } else { throw "Parameter '$_' is invalid -- must be one of: $($VALIDATE_SET_CERTIFICATE_STORE_NAME -join ",")"}})] [string] $StoreName = $DEFAULT_CERTIFICATE_STORE_NAME, [Parameter(Mandatory = $false, HelpMessage = "Specified the Windows Certificate Store Location to import the resulting certificate into. When omitted, this defaults to LocalMachine." )] [ValidateScript({if($_ -in $VALIDATE_SET_CERTIFICATE_STORE_LOCATION) { $true } else { throw "Parameter '$_' is invalid -- must be one of: $($VALIDATE_SET_CERTIFICATE_STORE_LOCATION -join ",")"}})] [string] $StoreLocation = $DEFAULT_CERTIFICATE_STORE_LOCATION, [Parameter(Mandatory=$false, HelpMessage="When applied, the script will not copy the resulting certificate files to a central location on the server" )] [switch] $SkipCentralize, [Parameter(Mandatory=$false, HelpMessage="Specifies the directory into which the resulting certificate files will be copied." )] [string] $CentralDirectory = $DEFAULT_CENTRAL_DIRECTORY, [Parameter(Mandatory=$false, HelpMessage="When applied, this function will not attempt to check the expiration date of the newest ACME cert in the cache in an effort to minimize excessive renewals" )] [switch] $SkipRenewalCheck, [Parameter(Mandatory=$false, HelpMessage="Specifies the method by which the function will determine if a certificate needs renewal (PA for posh acme [default], IIS for IIS binding, Directory for specific directory)" )] [ValidateScript({if($_ -in $VALIDATE_SET_RENEWAL_METHOD) { $true } else { throw "Parameter '$_' is invalid -- must be one of: $($VALIDATE_SET_RENEWAL_METHOD -join ",")"}})] [string] $RenewalMethod = $DEFAULT_RENEWAL_METHOD, [Parameter(Mandatory=$false, HelpMessage="Specifies the directory in which to look for the existing certificate in the case of the RenewalMethod being set to Directory" )] [string] $RenewalDirectory = $DEFAULT_RENEWAL_DIRECTORY, [Parameter(Mandatory = $false, HelpMessage = "When set, the domains specified in the -DomainList parameter will not be verified to ensure that they match the established allowed domain format." )] [switch] $SkipDomainValidation, [Parameter(Mandatory=$false, HelpMessage = "Specify the number of days prior to expiration of the current certificate that should be used to determine whether or not a new certificate should be requested." )] $RenewalThreshold = $DEFAULT_RENEWAL_THRESHOLD, [Parameter(Mandatory = $false, HelpMessage = "Optionally write debug information about the function's execution to a file and/or the event log" )] [Switch] $debugEnabled, [Parameter(Mandatory = $false, HelpMessage = "Optionally specify a directory to write a debug log file to" )] [string] $debugLogDirectory = $DEFAULT_DEBUG_LOG_DIRECTORY, [Parameter(Mandatory = $false, HelpMessage = "Optionally specify whether to log to the windows event log (EVT), a file (file) or both (both)" )] [ValidateScript({if($_ -in $VALIDATE_SET_DEBUG_MODE) { $true } else { throw "Parameter '$_' is invalid -- must be one of: $($VALIDATE_SET_DEBUG_MODE -join ",")"}})] [string] $debugMode = $DEFAULT_DEBUG_MODE, [Parameter(Mandatory = $false, HelpMessage = "Optionally specify a maximum number of seconds the function will wait beforesending a command to Posh-ACME to order a new certificate. This is useful to introduce jitter and prevent overloading the ACME server when multiple servers are scheduled to renew certificates at the same time." )] $jitter = $DEFAULT_JITTER, [Parameter(Mandatory = $false, HelpMessage = "Specifies the certificate key size and type" )] [ValidateScript({if (($_ -ne "ec-256") -and ($_ -ne "ec-384") -and !(($_ -ge 2048) -and ($_ -le 4096) -and ($_ % 128 -eq 0))) { throw "Parameter '$_' is invalid -- must be 'ec-256', 'ec-384', or a value from 2048 - 4096 which is also divisible by 128"} else { $true } })] [string]$CertKeyLength = $DEFAULT_CERT_KEY_LENGTH, [Parameter(Mandatory = $false, ValueFromRemainingArguments = $true )] $otherACMEArgs ) # check to see if the global debug environment variable is set if($null -ne $env:CERTIFICAT_DEBUG_ALWAYS){ $debugEnabled = $true } # Build a complete command of all parameters being used to run this function $ps5Command = "powershell.exe {import-module CertifiCat-PS -Force; $($MyInvocation.MyCommand) " $functionArgs = "" foreach($a in $PSBoundParameters.Keys){ if($PSBoundParameters[$a] -eq $true){ $functionArgs += "-$a " } else { $functionArgs += "-$a `"$($PSBoundParameters[$a])`" " } } $ps5Command += ("$functionArgs}") #begin building the function's return object $fro = [PSCustomObject]@{ FunctionName = $myinvocation.MyCommand; RunningPSVersion = $PSVersionTable.PSVersion.ToString(); PS5Command = $ps5Command; FunctionArguments = $functionArgs; FunctionSuccess = $true; Errors = @(); Certificate = @(); Bindings = @(); CertificateImported = $true; BindingsUpdated = $true; StoreLocation = $StoreLocation; StoreName = $StoreName; PFXPath = $PfxPath; ReadyForRenewal = $true; CertificateCentralized = $true; CentralDirectory = $CentralDirectory; CertificateFriendlyName = $FriendlyName; RenewalThreshold = $RenewalThreshold; debugEnabled= $debugEnabled; debugLogDirectory = $debugLogDirectory; debugMode = $debugMode; CertificateKeyLength = $CertKeyLength; MaxJitter = $jitter; ActualJitter = 0; } Write-FunctionBlock "[$($myinvocation.MyCommand)]" "Attempting to renew TLS Certificate" # Check to ensure that we're running from an elevated PowerShell session if(!(Assert-AdminAccess)) { Write-FunctionBlock "[$($myinvocation.MyCommand)]" "Completed unsuccessfully!" "red" $fro.Errors += "Session lacks administrative access. Ensure that PowerShell was run as an Administrator." $fro.FunctionSuccess = $false # write debug information if desired if($debugEnabled){ Write-ACMEDebug $myInvocation.MyCommand $fro $false $debugMode $debugLogDirectory } return $fro } ####################################### # Begin Parameter Pre-Fight Checks ####################################### Write-Host "-> Verifying that the Posh-ACME Module is installed and available..." -NoNewline if(!(Assert-PSACME)){ Write-Fail Write-Host "`tCould not load the Posh-ACME module... was it installed in the CurrentUser scope instead of LocalMachine? Cannot continue!" -ForegroundColor Red Write-FunctionBlock "[$($myinvocation.MyCommand)]" "Completed unsuccessfully!" "red" $fro.Errors += "Posh-ACME module was not found -- it might be missing, or have been installed in the scope of a different user, rather than LocalMachine" $fro.FunctionSuccess = $false $fro.BindingsUpdated = $false $fro.CertificateImported = $false $fro.CertificateCentralized = $false # write debug information if desired if($debugEnabled){ Write-ACMEDebug $myInvocation.MyCommand $fro $false $debugMode $debugLogDirectory } return $fro } else { Write-Ok } Write-Host "-> Validating incoming parameters..." -NoNewline # Check to see if we're going to update bindings, and, if so, if we're running in a modern (but unsupported) version of PowerShell if(($UpdateBindings) -and (!(Assert-PSVersion))){ Write-Fail Write-Host "`tDetected this function running from a modern PowerShell console. This combination of parameters REQUIRES the use of PowerShell 6 or earlier. Check the 'PS5Command' property of the return object for a complete command to run instead." -ForegroundColor Red Write-FunctionBlock "[$($myinvocation.MyCommand)]" "Completed unsuccessfully!" "red" $fro.Errors += "Function/parameters require PowerShell 6 or earlier, but running from a modern console. See the PS5Command property for a PowerShell 5 equivalent to run." $fro.FunctionSuccess = $false $fro.BindingsUpdated = $false $fro.CertificateImported = $false $fro.CertificateCentralized = $false # write debug information if desired if($debugEnabled){ Write-ACMEDebug $myInvocation.MyCommand $fro $false $debugMode $debugLogDirectory } return $fro } # Check to see if we're using an IIS RenewalMethod, and if so, if we're running a modern (but unsupported) version of powershell if(($RenewalMethod -eq "IIS") -and (!(Assert-PSVersion))){ Write-Fail Write-Host "`tDetected this function running from a modern PowerShell console. This combination of parameters REQUIRES the use of PowerShell 6 or earlier. Check the 'PS5Command' property of the return object for a complete command to run instead." -ForegroundColor Red Write-FunctionBlock "[$($myinvocation.MyCommand)]" "Completed unsuccessfully!" "red" $fro.Errors += "Function/parameters require PowerShell 6 or earlier, but running from a modern console. See the PS5Command property for a PowerShell 5 equivalent to run." $fro.FunctionSuccess = $false $fro.BindingsUpdated = $false $fro.CertificateImported = $false $fro.CertificateCentralized = $false # write debug information if desired if($debugEnabled){ Write-ACMEDebug $myInvocation.MyCommand $fro $false $debugMode $debugLogDirectory } return $fro } # Check to make sure we aren't attempting to update binding(s) with a certificate that's being imported into the CurrentUser store if(($UpdateBindings) -and $($StoreLocation -ne "LocalMachine")){ Write-Fail "`tWhen the -UpdateBindings switch is applied to this function, the -StoreLocation parameter MUST be LocalMachine!" | Write-Host -ForegroundColor Red -BackgroundColor Black Write-FunctionBlock "[$($myinvocation.MyCommand)]" "Completed unsuccessfully!" "red" $fro.Errors += "UpdateBindings switch applied, but -StoreLocation parameter set to $StoreLocation. To update Site bindings, StoreLocation MUST be LocalMachine" $fro.FunctionSuccess = $false $fro.BindingsUpdated = $false $fro.CertificateImported = $false $fro.CertificateCentralized = $false # write debug information if desired if($debugEnabled){ Write-ACMEDebug $myInvocation.MyCommand $fro $false $debugMode $debugLogDirectory } return $fro } # Check to ensure that we aren't attempting to update IIS bindings but skipping the import into the cert store if(($UpdateBindings) -and ($SkipImport)){ Write-Fail "`tWhen the -UpdateBindings switch is applied to this function, you cannot also specify the -SkipImport switch!" | Write-Host -ForegroundColor Red -BackgroundColor Black Write-FunctionBlock "[$($myinvocation.MyCommand)]" "Completed unsuccessfully!" "red" $fro.Errors += "UpdateBindings and SkipImport switches both specified -- When UpdateBindings is specified, SkipImport must NOT be present" $fro.FunctionSuccess = $false $fro.BindingsUpdated = $false $fro.CertificateImported = $false $fro.CertificateCentralized = $false # write debug information if desired if($debugEnabled){ Write-ACMEDebug $myInvocation.MyCommand $fro $false $debugMode $debugLogDirectory } return $fro } # Validate the domain names to ensure that they match the allowed pattern, unless we are specifically bypassing this check $invalidDomains = @() foreach($domain in $DomainList){ if(!($domain -match $VALIDATE_PATTERN_DOMAIN_NAME)){ $invalidDomains += $domain } } if(($invalidDomains.Count -gt 0) -and (!($SkipDomainValidation))){ Write-Fail "`tDetected $($invalidDomains.Count) domains in the certificate request that do not meet the the domain name pattern '$VALIDATE_PATTERN_DOMAIN_NAME': ($($invalidDomains -join ',')). Add the -SkipDomainValidation switch to override this check." | Write-Host -ForegroundColor Red -BackgroundColor Black Write-FunctionBlock "[$($myinvocation.MyCommand)]" "Completed unsuccessfully!" "red" $fro.Errors += "-SkipDomainValidation switch was not specified, but detected the following non-conforming domains in the certificate request: $($invalidDomains -join ","). Domains must match the pattern: '$VALIDATE_PATTERN_DOMAIN_NAME'" $fro.FunctionSuccess = $false $fro.BindingsUpdated = $false $fro.CertificateImported = $false $fro.CertificateCentralized = $false # write debug information if desired if($debugEnabled){ Write-ACMEDebug $myInvocation.MyCommand $fro $false $debugMode $debugLogDirectory } return $fro } # validate the -CertKeyLength parameter... the ValidateScript directive will take care of some of this, but this serves as an additional double check if the default value was overwritten via an environment variable if (($CertKeyLength -ne "ec-256") -and ($CertKeyLength -ne "ec-384") -and !(($CertKeyLength -ge 2048) -and ($CertKeyLength -le 4096) -and ($CertKeyLength % 128 -eq 0))) { Write-Fail "`tThe -CertKeyLength parameter is not a valid value. It must be 'ec-256', 'ec-384', or a value from 2048 - 4096 which is also divisible by 128. However, the value '$CertKeyLength' was provided." | Write-Host -ForegroundColor Red -BackgroundColor Black Write-FunctionBlock "[$($myinvocation.MyCommand)]" "Completed unsuccessfully!" "red" $fro.Errors += "Invalid value for -CertKeyLength provided ('$CertKeyLength') -- must be 'ec-256', 'ec-384', or a value from 2048 - 4096 which is also divisible by 128" $fro.FunctionSuccess = $false $fro.BindingsUpdated = $false $fro.CertificateImported = $false $fro.CertificateCentralized = $false # write debug information if desired if($debugEnabled){ Write-ACMEDebug $myInvocation.MyCommand $fro $false $debugMode $debugLogDirectory } return $fro } Write-Ok # Check to see if the SkipCentralize switch is applied alongside a custom CentralDirectory parameter # (We'll just warn the user and ignore the CentralDirectory param -- we won't do a hard-stop) if(($SkipCentralize) -and ($CentralDirectory -ne $DEFAULT_CENTRAL_DIRECTORY)){ Write-Host "`tWARNING: Ignoring the -CentralDirectory parameter value due to the presence of the -SkipCentralize parameter!" -ForegroundColor Yellow -BackgroundColor Black } # Check to see if the -BindingPorts parameter is populated, without the -UpdateBindings switch # (We'll just warn the user and ignore the binding updates -- we won't do a hard-stop) if(($BindingPorts -ne "") -and ($null -ne $BindingPorts) -and (!$UpdateBindings)){ "`tWARNING: The -BindingPorts parameter was populated, but the -UpdateBindings switch was not specified! Bindings WILL NOT be updated automatically!" | Write-Host -ForegroundColor Yellow } # Update the certificate central directory with the primary domain name and timestamp $centralDirectory = "$centralDirectory\$($DomainList[0])\$(get-date -format "MM-dd-yyyy-HH-mm-ss")" # Parse the remaining arguments that were passed in that should be passed to Posh-ACME $otherArgsSplat = @{ } for($argNum = 0; $argNum -lt $otherACMEArgs.Count; $argNum++){ if((($otherACMEArgs[($argNum + 1)]) -Match "^-") -or ($null -eq $otherACMEArgs[($argNum + 1)])){ $otherArgsSplat.Add($otherACMEArgs[$argNum], $true) } else { $otherArgsSplat.Add($otherACMEArgs[$argNum], $otherACMEArgs[($argNum+1)]) $argNum += 1 } } ####################################### # End Parameter Pre-Fight Checks ####################################### # Check to see if we need to even attempt a certificate renewal $tgtDomain = ($DomainList -split ",")[0] Write-Host "-> Performing renewal check for primary domain '$tgtDomain' using the '$($RenewalMethod)' method..." -NoNewline if(!($SkipRenewalCheck)){ $canContinue = Confirm-ACMERenewalReadiness -ChainedCall -DomainName $tgtDomain -RenewalMethod $RenewalMethod -RenewalDirectory $RenewalDirectory -BindingPorts $BindingPorts -RenewalThreshold $RenewalThreshold if($canContinue.ReadyForRenewal -ne $true){ Write-Host "`nAt least one valid certificate was found -- renewal will not continue! To force a renwal, use the -SkipRenewalCheck switch." -ForegroundColor Yellow Write-FunctionBlock "[$($myinvocation.MyCommand)]" "Completed successfully!" "green" $fro.BindingsUpdated = $false $fro.CertificateImported = $false $fro.CertificateCentralized = $false $fro.CentralDirectory = "" $fro.ReadyForRenewal = $false # write debug information if desired if($debugEnabled){ Write-ACMEDebug $myInvocation.MyCommand $fro $true $debugMode $debugLogDirectory } return $fro } } else { Write-Skipped } # We are going to request a new certificate at this point -- check to see if we need to introduce jitter if(($null -ne $jitter) -and ($jitter -gt 0)){ $jitter = Get-Random -Minimum 1 -Maximum $jitter # capture the actual jitter value we're using so that the user knows how long we actual waited $fro.ActualJitter = $jitter Write-Host "-> Pausing for $jitter seconds to introduce jitter and prevent too many simultaneous certificate requests across the environment..." Start-Sleep -Seconds $jitter } Write-Host "-> Requesting new certificate from ACME server..." -NoNewline $cert = New-PACertificate -Domain $DomainList -FriendlyName $FriendlyName -Plugin WebSelfHost -Force -AlwaysNewKey -CertKeyLength $CertKeyLength @otherArgsSplat if($null -eq $cert){ Write-Fail Write-Host "`tAn error occurred while attempting to request the new certificate -- script will not continue!" -ForegroundColor Red -BackgroundColor Black Write-FunctionBlock "[$($myinvocation.MyCommand)]" "Completed unsuccessfully!" "red" $fro.FunctionSuccess = $false $fro.BindingsUpdated = $false $fro.CertificateImported = $false $fro.CertificateCentralized = $false $fro.Errors += "Error occurred attempting to obtain new Posh-ACME certificate" # write debug information if desired if($debugEnabled){ Write-ACMEDebug $myInvocation.MyCommand $fro $false $debugMode $debugLogDirectory } return $fro } else { Write-Ok } # copy the certificate to a central location, if needed $CertCurrentDirectory = $cert.certfile.replace("cert.cer", "") if($SkipCentralize){ Write-Skipped $fro.PFXPath = "$CertCurrentDirectory\cert.pfx" } else { $centralizedOK = Copy-CertificateToCentralDirectory $CentralDirectory $CertCurrentDirectory switch($centralizedOK){ "directory"{ Write-Host "`t`tAn error occurred creating new directory -- copy cannot continue!" -ForegroundColor Red $fro.FunctionSuccess = $false $fro.BindingsUpdated = $false $fro.CertificateImported = $false $fro.CertificateCentralized = $false $fro.Errors += "Error occurred attempting to create central directory '$CentralDirectory'" # write debug information if desired if($debugEnabled){ Write-ACMEDebug $myInvocation.MyCommand $fro $false $debugMode $debugLogDirectory } return $fro } "copy"{ Write-Host "`t`tDue to a failure copying the certificate files, we won't proceed with any additional actions!" -ForegroundColor Red $fro.FunctionSuccess = $false $fro.BindingsUpdated = $false $fro.CertificateImported = $false $fro.CertificateCentralized = $false $fro.Errors += "Error occurred attempting to copy certificate files from '$CertCurrentDirectory' to '$CentralDirectory'" # write debug information if desired if($debugEnabled){ Write-ACMEDebug $myInvocation.MyCommand $fro $false $debugMode $debugLogDirectory } return $fro } default{ $fro.PFXPath = "$CentralDirectory\cert.pfx" } } } # import the resultant certificate into the windows certificate store Write-Host "-> Importing certificate into the $StoreLocation\$StoreName Store..." -NoNewline if($SkipImport){ Write-Skipped } else { Install-PACertificate $cert -StoreLocation $StoreLocation -StoreName $StoreName #make sure we imported the certificate successfully $importedCert = get-item "cert:\$StoreLocation\$StoreName\$($cert.thumbprint)" if($null -ne $importedCert){ Write-Ok $fro.Certificate = $importedCert } else { Write-Fail Write-Host "`tDue to a failure importing the new certificate into the certificate store, we won't proceed with any additional actions!" -ForegroundColor Red Write-FunctionBlock "[$($myinvocation.MyCommand)]" "Completed unsuccessfully!" "red" $fro.FunctionSuccess = $false $fro.BindingsUpdated = $false $fro.CertificateImported = $false $fro.CertificateCentralized = $false $fro.Errors += "Error occurred attempting to import new certificate into cert:\$CertLocation\$CertStore" # write debug information if desired if($debugEnabled){ Write-ACMEDebug $myInvocation.MyCommand $fro $false $debugMode $debugLogDirectory } return $fro } } # check to see if we're going to update IIS bindings Write-Host "-> Updating IIS Site Bindings..." -NoNewLine if($UpdateBindings){ Write-Pending $updatedBindings = Update-IISBindings $BindingPorts $StoreName $importedCert.Thumbprint #remove the phantom $nulls appearing in the list $updatedBindings = $updatedBindings | where-object {$_ -ne $Null } $fro.Bindings = $updatedBindings if(($updatedBindings | Where-Object {$_.UpdatedSuccessfully -eq $false}).Count -eq 0){ Write-FunctionBlock "[$($myinvocation.MyCommand)]" "Completed successfully!" "green" # write debug information if desired if($debugEnabled){ Write-ACMEDebug $myInvocation.MyCommand $fro $true $debugMode $debugLogDirectory } return $fro } else { Write-FunctionBlock "[$($myinvocation.MyCommand)]" "Completed unsuccessfully!" "red" $fro.Errors += "Error(s) occurred updating one or more bindings. Please review the Bindings property of this return object for more details." $fro.BindingsUpdated = $false # write debug information if desired if($debugEnabled){ Write-ACMEDebug $myInvocation.MyCommand $fro $false $debugMode $debugLogDirectory } return $fro } } else { Write-Skipped Write-FunctionBlock "[$($myinvocation.MyCommand)]" "Completed successfully!" "green" $fro.BindingsUpdated = $false # write debug information if desired if($debugEnabled){ Write-ACMEDebug $myInvocation.MyCommand $fro $true $debugMode $debugLogDirectory } return $fro } } |