├── .gitattributes ├── .github └── workflows │ └── codesee-arch-diagram.yml ├── README.md ├── Check_IIS_Certificate_Expiration.ps1 └── Lets-Encrypt_Automate_PowerShell.ps1 /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto -------------------------------------------------------------------------------- /.github/workflows/codesee-arch-diagram.yml: -------------------------------------------------------------------------------- 1 | # This workflow was added by CodeSee. Learn more at https://codesee.io/ 2 | # This is v2.0 of this workflow file 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request_target: 8 | types: [opened, synchronize, reopened] 9 | 10 | name: CodeSee 11 | 12 | permissions: read-all 13 | 14 | jobs: 15 | codesee: 16 | runs-on: ubuntu-latest 17 | continue-on-error: true 18 | name: Analyze the repo with CodeSee 19 | steps: 20 | - uses: Codesee-io/codesee-action@v2 21 | with: 22 | codesee-token: ${{ secrets.CODESEE_ARCH_DIAG_API_TOKEN }} 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lets-Encrypt_Automate_PowerShell 2 | 3 | This PowerShell script automates the process of generating a LetsEncrypt SSL Certificate and assigning it to an IIS Site. It will either create an HTTPS binding for a Site or it can Renew a current HTTPS Binding with a new Certificate. 4 | 5 | It requries that ACMESharp is installed on the Server - https://github.com/ebekker/ACMESharp 6 | 7 | Thanks to Rick Strahl for the initial idea - https://weblog.west-wind.com/posts/2016/feb/22/using-lets-encrypt-with-iis-on-windows#TheEasyWay:LetsEncrypt-Win-Simple 8 | 9 | The script should be ran on the IIS Server with Administrative Priviliges. 10 | 11 | You should initialize the ACME Vault and setup an ACME Registration before running the script if you havent already. 12 | 13 | This is done through two simple commands: 14 | 15 | Initialize-ACMEVault 16 | 17 | New-AcmeRegistration -Contacts "$email" -AcceptTos | out-null 18 | 19 | The script is stripped of any host ouput since it is designed to be called automatically. 20 | 21 | # Parameters 22 | 23 | The Script has three parameters: 24 | 25 | domain - This is the DNS. It should be accessable from the Internet. 26 | 27 | iisSiteName - This is the Name of the Site as seen in the IIS Management Console. 28 | 29 | renew - If you are creating a Certificate for this Site for the First time this should be "False". If you are renewing a certificate set it to "True" 30 | 31 | .\PATHTOSCRIPT\Lets-Encrypt_Automate_PowerShell.ps1 -domain "reportifier.com" -iisSiteName "reportifier.com" -renew "False" 32 | 33 | More Info: http://georgelubomirov.blogspot.bg/2017/11/automatic-issuance-and-renewal-of-ssl.html 34 | 35 | There is also a suplementary script checking IIS Site Bindings with soon to expire certificates and calling the main script. It works best as a Task Scheduled Job. 36 | 37 | The Script is called without parameters (again to faciliate easier calling from Task Scheduler). You have to change two things directly into the script. 38 | 39 | The Path to the Main script, which is called for the bindings which will expire. 40 | 41 | The number of days since the day of running the script in which the certificates will expire. 42 | 43 | Both parameters are in the last 5 lines of the script. 44 | 45 | With this you wouldn't have to worry about manually renewing certificates anymore. -------------------------------------------------------------------------------- /Check_IIS_Certificate_Expiration.ps1: -------------------------------------------------------------------------------- 1 | function checkForCertificateExpiration($daysExpiry, $renewCerts){ 2 | ####Check if Script is Ran as Administrator. Otherwize IIS info is not available. 3 | If (-NOT ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")){ 4 | write-host "This script needs to be run As Administrator" -ForegroundColor Red 5 | Break 6 | } 7 | 8 | ####Get Todays Date 9 | $todayDate = get-date 10 | 11 | ####Array for All Info 12 | $certInfo=@() 13 | 14 | ####Get All HTTPS Bindings 15 | $bindings = Get-WebBinding | where {$_.protocol -eq "https"} 16 | 17 | foreach($binding in $bindings){ 18 | ####Get Certificate for the Binding from Certificate Store. Try Web Hosting Store first and if the Cert is not there try the Personal Store 19 | $obj = dir "Cert:LocalMachine\WebHosting" -recurse | where {$_.Thumbprint -eq $binding.certificateHash} 20 | 21 | if(!($obj)){ 22 | $obj = dir "Cert:LocalMachine\My" -recurse | where {$_.Thumbprint -eq $binding.certificateHash} 23 | } 24 | 25 | 26 | ####Put relevant info into Array 27 | $certInfo += [PSCustomObject] @{ 28 | ####Regex to get Site Name. Example "/system.applicationHost/sites/site[@name='WebAppsHTTPRedir' and @id='22']" 29 | SiteName = (($binding.ItemXPath -split ([RegEx]::Escape("[@name='")))[1]).split("'")[0] 30 | BindingInformation = $binding.bindingInformation 31 | Hash = $binding.certificateHash 32 | FriendlyName = $obj.FriendlyName 33 | NotAfter = $obj.NotAfter 34 | } 35 | } 36 | ####Array for Certificates nearing expiry 37 | $warningForExpiration=@() 38 | foreach($cert in $certInfo){ 39 | if($cert.NotAfter){ 40 | ####Check if Certificate Expiration mathes the rule 41 | if($cert.NotAfter -lt $todayDate.AddDays($daysExpiry)){ 42 | 43 | ####Add Object to Array 44 | $warningForExpiration+=$cert 45 | } 46 | } 47 | } 48 | 49 | if($renewCerts -eq "true"){ 50 | ############################################ 51 | ##Prepare Multi-threading 52 | ############################################ 53 | 54 | ####Create an empty array for multi-threading 55 | $RunspaceCollection = @() 56 | 57 | ####This is the array we want to ultimately add our information to 58 | [Collections.Arraylist]$dataFull = @() 59 | 60 | ####Create the sessionstate variable entry 61 | $varPass = New-object System.Management.Automation.Runspaces.SessionStateVariableEntry -ArgumentList 'varPass',$Form,$Null 62 | 63 | $InitialSessionState = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault() 64 | 65 | ####Variables passed to every Runspace. Not Used, left for reference. 66 | #$bindingRegex = [System.String]::Empty 67 | #$siteName = [System.String]::Empty 68 | 69 | ####Add the variable to the sessionstate. Not Used, left for reference. 70 | #$InitialSessionState.Variables.Add($bindingRegex) 71 | #$InitialSessionState.Variables.Add($siteName) 72 | 73 | ####Create a Runspace Pool with a minimum and maximum number of run spaces. (http://msdn.microsoft.com/en-us/library/windows/desktop/dd324626(v=vs.85).aspx) 74 | ####Edit how many sessions will be open - this is effectivelly how many users access the system at any time 75 | $RunspacePool = [RunspaceFactory]::CreateRunspacePool(1,1,$InitialSessionState, $host) 76 | 77 | ####Open the RunspacePool 78 | $RunspacePool.Open() 79 | 80 | $ScriptBlock = { 81 | Param($bindingRegex, $siteName) 82 | ."C:\PS_Cert\certReq.ps1" -domain $bindingRegex -iisSiteName $siteName -renew "True" 83 | } 84 | 85 | ############################################ 86 | ##Start Certificate Issuance 87 | ############################################ 88 | foreach($cert in $warningForExpiration){ 89 | $bindingRegex = ($cert.BindingInformation -split "443:")[1] 90 | $siteName = $cert.SiteName 91 | 92 | ####You can exclude sites here 93 | #if($siteName -notlike "domain.com"){ 94 | $Powershell = [PowerShell]::Create().AddScript($ScriptBlock).AddArgument($bindingRegex).AddArgument($siteName) 95 | 96 | $Powershell.RunspacePool = $RunspacePool 97 | 98 | [Collections.Arraylist]$RunspaceCollection += New-Object -TypeName PSObject -Property @{ 99 | Runspace = $PowerShell.BeginInvoke() 100 | PowerShell = $PowerShell 101 | } 102 | #} 103 | } 104 | 105 | ############################################ 106 | ##Dispose of Threads 107 | ############################################ 108 | 109 | ####Here we collect the Data and dispose of the Runspaces 110 | While($RunspaceCollection) { 111 | 112 | ####Just a simple ForEach loop for each Runspace to get resolved 113 | Foreach ($Runspace in $RunspaceCollection.ToArray()) { 114 | 115 | ####Here's where we actually check if the Runspace has completed 116 | If ($Runspace.Runspace.IsCompleted) { 117 | 118 | ####Since it's completed, we get our results here 119 | [void]$dataFull.Add($Runspace.PowerShell.EndInvoke($Runspace.Runspace)) 120 | 121 | ####Here's where we cleanup our Runspace 122 | $Runspace.PowerShell.Dispose() 123 | $RunspaceCollection.Remove($Runspace) 124 | 125 | } #/If 126 | } #/ForEach 127 | } #/While 128 | 129 | } else { 130 | 131 | $notificationMail = @() 132 | if($warningForExpiration){ 133 | foreach($cert in $warningForExpiration){ 134 | $bindingRegex = ($cert.BindingInformation -split "443:")[1] 135 | $siteName = $cert.SiteName 136 | 137 | ####You can exclude sites here 138 | ##if($siteName -notlike "domain.com"){ 139 | $notificationMail+=$bindingRegex 140 | ##} 141 | } 142 | } 143 | 144 | if($notificationMail){ 145 | $notificationMail | out-gridview 146 | } 147 | 148 | } 149 | } 150 | 151 | ##Renew 152 | checkForCertificateExpiration -daysExpiry 3 -renewCerts "true" 153 | 154 | -------------------------------------------------------------------------------- /Lets-Encrypt_Automate_PowerShell.ps1: -------------------------------------------------------------------------------- 1 | param([string]$domain,[string]$iisSiteName, [string]$renew); 2 | 3 | ####PARAMETERS 4 | ## domain - This is the DNS of the Site. It should be accessable from the Internet. 5 | ## iisSiteName - This is the Name of the Site as seen in the IIS Management Console. 6 | ## renew - If you are creating a Certificate for this Site for the First time this should be "False". If you are renewing a certificate set it to "True" 7 | 8 | 9 | ####EXAMPLE 10 | ## .\PATHTOSCRIPT\Lets-Encrypt_Automate_PowerShell.ps1 -domain "reportifier.com" -iisSiteName "reportifier.com" -renew "False" 11 | 12 | ############################################################################################### 13 | ##Initialize 14 | ############################################################################################### 15 | $ErrorActionPreference = "Stop" 16 | 17 | ####Check if PowerShell is ran as Administrator. IIS is not available without Admin Priviliges 18 | If(-NOT ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")){ 19 | return "This script needs to be run As Administrator" 20 | Break 21 | } 22 | 23 | 24 | ####Import ACMESharp 25 | try{ 26 | import-module ACMESharp 27 | } catch{ 28 | return "Couldn't load ACMESharp." 29 | break 30 | } 31 | 32 | ####Get script location 33 | $scriptpath = $MyInvocation.MyCommand.Path 34 | $dir = Split-Path $scriptpath 35 | 36 | ####Fail Variables 37 | $failInvalid = $false 38 | $failOnCert = $false 39 | $finalStatus = "Success" 40 | 41 | ####The ACME ALias is set to be the same as the Domain. 42 | $alias = $domain 43 | 44 | ####Cert Paths 45 | $certname = $alias +"_"+"$(get-date -format yyyy-MM-dd--HH-mm)" 46 | $pfxfile = "$dir\$certname.pfx" 47 | 48 | $SiteFolder = Join-Path -Path 'C:\inetpub\wwwroot' -ChildPath $iissitename 49 | 50 | $initializevault = $FALSE 51 | $createregistration = $FALSE 52 | 53 | #Not used. Left for Reference. If ACME Vault is not initialized it should be before running the script. This is a one time operation. 54 | if($initializevault) { 55 | Initialize-ACMEVault 56 | } 57 | 58 | #Not used. Left for Reference. If there is no ACME Registration you should create on before running the script. This is a one time operation. 59 | if($createregistration) { 60 | # Set up new 'account' tied to an email address 61 | New-AcmeRegistration -Contacts "$email" -AcceptTos | out-null 62 | start-sleep -Seconds 2 63 | } 64 | 65 | ####Get Acme Vault 66 | $vault = Get-ACMEVault -VaultProfile :sys 67 | 68 | ####Change to the Vault folder 69 | cd "C:\ProgramData\ACMESharp\sysVault" 70 | 71 | ####Check if alias already created. This is obsolete. Left for reference. 72 | #$aliasCheck = $vault.Identifiers | where {$_.Alias -eq $alias} 73 | 74 | #if($aliasCheck){ 75 | # $createalias = $TRUE 76 | #} else { 77 | # $createalias = $TRUE 78 | #} 79 | 80 | ###Due to "authorizations for these names not found or expired" error we now create an Alias every time (for both - New Bindings and Renewals). The reason for this 81 | ##is that the authorization for the domain is active for one month but the certificates are for 3 months, so alot of users started getting this error after trying 82 | ##to renew their certificate after three months. 83 | 84 | $createalias = $TRUE 85 | 86 | ####Generate Random Alias 87 | $alias = $alias + -join ((1..10) | %{(65..90) + (97..122) | Get-Random} | % {[char]$_}) 88 | 89 | ############################################################################################### 90 | ##Functions 91 | ############################################################################################### 92 | 93 | ####Check the Request Status is ready before continuing. Keeps checking until Status is Valid or Failed. 94 | function checkReqStatus { 95 | $statusFull = (Update-ACMEIdentifier $alias -ChallengeType http-01).Challenges 96 | $statusHTTP = $statusFull | where {$_.Type -eq "http-01"} 97 | 98 | if($statusHTTP.status -eq "Pending"){ 99 | ####Loop Again 100 | Start-Sleep -Seconds 10 101 | checkReqStatus 102 | } elseif ($statusHTTP.status -eq "Valid"){ 103 | ####Continue with script 104 | } else { 105 | $failInvalid = $true 106 | } 107 | } 108 | 109 | ####Check if Certificate is Ready before Downloading. If Certificate is not ready after 5 tries it fails. 110 | function checkCertStatus { 111 | $i++ 112 | $certFull = update-AcmeCertificate $certname 113 | if($certFull.Alias){ 114 | if((!($certFull.IssuerSerialNumber)) -and ($i -le 5)){ 115 | Start-Sleep -Seconds 10 116 | checkCertStatus 117 | } 118 | } else { 119 | $failOnCert = $true 120 | } 121 | } 122 | 123 | ############################################################################################### 124 | ##Core 125 | ############################################################################################### 126 | 127 | ####Check if Binding already Exists 128 | $obj = get-webconfiguration "//sites/site[@name='$iissitename']" 129 | $binding = $obj.bindings.Collection | where {(($_.protocol -eq "HTTPS") -and ($_.bindingInformation -eq ("*:443:" + $domain)))} 130 | 131 | 132 | ####Proceed only if there is no such binding or if the renew param is true 133 | if((!($binding)) -or ($renew -eq "True")){ 134 | 135 | ############################################################################################### 136 | ##New Alias 137 | ############################################################################################### 138 | if($createalias){ 139 | ####Associate a new site 140 | try{ 141 | New-AcmeIdentifier -Dns $domain -Alias $alias -ErrorAction Stop | out-null 142 | } catch { 143 | $finalStatus = "Error: AcmeIdentifier already exists or creation failed" 144 | return "Error: AcmeIdentifier already exists or creation failed" 145 | } 146 | start-sleep -Seconds 2 147 | 148 | ####Prove the site exists and is accessible 149 | try{ 150 | Complete-ACMEChallenge $alias -ChallengeType http-01 -Handler iis -HandlerParameters @{WebSiteRef="$iissitename"} -ErrorAction Stop | out-null 151 | } catch { 152 | $finalStatus = "Error: ACMEChallenge Complete Failed" 153 | return "Error: ACMEChallenge Complete Failed" 154 | } 155 | start-sleep -Seconds 2 156 | 157 | ####Validate site 158 | try { 159 | Submit-ACMEChallenge $alias -ChallengeType http-01 -ErrorAction Stop | out-null 160 | } catch { 161 | $finalStatus = "Error: ACMEChallenge Submit Failed" 162 | return "Error: ACMEChallenge Submit Failed" 163 | } 164 | 165 | ####Check until Pending changes to Valid or Invalid 166 | checkReqStatus 167 | } 168 | 169 | ############################################################################################### 170 | ##Generate Certificate 171 | ############################################################################################### 172 | if($failInvalid -eq $false){ 173 | ####Generate a certificate 174 | New-ACMECertificate ${alias} -Generate -Alias $certname | out-null 175 | start-sleep -Seconds 2 176 | ####Submit the certificate 177 | Submit-ACMECertificate $certname | out-null 178 | start-sleep -Seconds 2 179 | 180 | ####Check Certificate Status until Certificate is Ready 181 | $i = 0 182 | checkCertStatus 183 | 184 | if($failOnCert -eq $false){ 185 | ####Export Certificate to PFX file 186 | Get-ACMECertificate $certname -ExportPkcs12 $pfxfile | out-null 187 | start-sleep -Seconds 2 188 | ####Import Certificate 189 | $certRootStore = “LocalMachine” 190 | $certStore = "My" 191 | $pfx = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 192 | $pfx.import($pfxfile,$pfxPass,“Exportable,MachineKeySet,PersistKeySet”) 193 | $store = New-Object System.Security.Cryptography.X509Certificates.X509Store($certStore,$certRootStore) 194 | $store.Open('ReadWrite') 195 | $store.Add($pfx) 196 | $store.Close() 197 | $certThumbprint = $pfx.Thumbprint 198 | 199 | if($renew -eq "False"){ 200 | ####Create Binding 201 | try{ 202 | New-WebBinding -Name $iissitename -IPAddress "*" -Port 443 -Protocol "https" -HostHeader $domain -SslFlags 1 -ErrorAction Stop 203 | } catch{ 204 | return "Error: New Web Binding Failed" 205 | } 206 | } 207 | ####Set Certificate 208 | $obj = get-webconfiguration "//sites/site[@name='$iissitename']" 209 | $binding = $obj.bindings.Collection | where {(($_.protocol -eq "HTTPS") -and ($_.bindingInformation -eq ("*:443:" + $domain)))} 210 | $method = $binding.Methods["AddSslCertificate"] 211 | $methodInstance = $method.CreateInstance() 212 | $methodInstance.Input.SetAttributeValue("certificateHash", $certThumbprint) 213 | $methodInstance.Input.SetAttributeValue("certificateStoreName", $certStore) 214 | $methodInstance.Execute() 215 | } else { 216 | return "Error: Generation of Certificate failed" 217 | } 218 | 219 | } else { 220 | return "Error: ACMEChallenge invalid." 221 | } 222 | 223 | } else { 224 | return "Error: Binding already exists. Renew Off." 225 | } 226 | return $finalStatus 227 | 228 | --------------------------------------------------------------------------------