x

Automatic IIS Certificates on Windows Server with DNS Server Verification

For far too long, Linux sysadmins have been taking full advantage of Let’s Encrypt and ACME certificate generation. Sure, certbot does run on Windows Server, but… not very well, in my experience. And deploying to IIS is usually a pain, as well as dns-01 domain verification for DNS Server.

Enter Posh-ACME. A simple PowerShell module to automate ACME certificate generation, domain verification, and renewal, with the same ease as those dirty penguinistas.

This guide assumes you are working in an Active Directory environment with domain-joined servers. You may need to tweak some steps if you are not.

Preparing the DNS Server for remote connections

Before automating anything, PowerShell remoting needs to be enabled on the DNS Server host. How exactly to do that is outside the scope of this article, and Microsoft has plenty of excellent guides on the topic already. But the gist of it, is that you must have the following things:
1. Enable Windows Remote Management (WinRM) on the DNS Server host;
2. Ensure any intermediary firewalls will not block connections between the host and any client machines;
3. Generate a TLS certificate on the host;
4. Export the certificate private key so that it can be imported to client machines.
See the below article from Microsoft on getting this working:
Security considerations for PowerShell Remoting using WinRM - PowerShell | Microsoft Learn
Additionally, the Posh-ACME project page provides more information on their specifics and some other helpful links:
Windows - Posh-ACME

But if you just want it to work, this should get you started in most cases for most environments:

# NOTE: on modern versions of Windows, the hostname is required instead of IP
$hostname = ('{0}.{1}' -f $env:COMPUTERNAME, $env:USERDNSDOMAIN).ToLower()
# generate the cert and install it
$cert = New-SelfSignedCertificate -DnsName $hostname `
                                  -CertStoreLocation cert:\LocalMachine\My
# add a new WinRM listener that uses our new cert
winrm create winrm/config/Listener?Address=*+Transport=HTTPS `
  '@{Hostname="{0}";CertificateThumbprint="{1}"}' `
    -f $hostname, $cert.ThumbPrint
# expose it through the firewall
netsh advfirewall firewall add rule `
  name="WinRM-HTTPS" dir=in localport=5986 protocol=TCP action=allow

Once you have everything set up, you need to export the certificate, including private key. You can do so using the Certificate Management snap-in (certmgr.msc targeting the Local machine) or the following PowerShell:

Export-PfxCertificate $cert -FilePath mycert.pfx -Password 'mypassword'

Either host the exported certificate on a network share, or copy it to the client machine(s) you will be configuring, as each client requires the certificate to connect.

Certificate Authorities can skip the above

If you have a Certificate Authority in your domain, you can use a certificate that it generates, and as long as that CA is trusted on the clients that will be connecting to the host machine, you do not have to import the certificate onto your client servers.

Setting up Posh-ACME on the client

  1. Run an elevated PowerShell window as the service account/domain user that will be owning the renewal process, and that has administrative rights on the host server. Or, at least, rights to be able to connect with WinRM, and to add/update/remove DNS entries on the server. How to do that is outside the scope of this article.
    Elevation as another user

    If you are not logged in as the user that will be renewing the cert on the client server, it is difficult to run an elevated process as them. Shift-right-click and run PowerShell as the other user, then run the following code to elevate privileges:

    Start-Process 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe' -Verb RunAs
  1. Older versions of Windows/.NET Framework may not have TLS 1.2 enabled by default. Enable it if necessary:
    [Net.ServicePointManager]::SecurityProtocol = `
      [Net.ServicePointManager]::SecurityProtocol -bor `
      [Net.SecurityProtocolType]::Tls12
  1. Install RSAT module and certificate to connect to the host server:
    Import-Certificate -Filepath \\yourserver\yourshare\mycert.pfx -CertStoreLocation Cert:\LocalMachine\Root
    Install-WindowsFeature RSAT-DNS-Server
  1. Install .NET Framework >= 4.7.1 if required:
    if (-not (Get-ItemProperty "HKLM:SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full").Release -ge 461308) {
        & \\yourserver\yourshare\ndp48-x86-x64-allos-enu.exe
        Write-Host 'System may need to be rebooted for .NET installation'
        Read-Host 'Press enter after installation is finished...'
    } else {
        Write-Host 'Installed .NET Framework version sufficient'
    }
  1. Install modules:
    If you have not used PowerShellGet or the NuGet module provider, you may be prompted to install and/or trust the NuGet provider.
Windows Server 2012 and below may not have a version of PowerShell with PowerShellGet installed. It is easiest and best practice to install the latest Windows Management Framework update, which includes updates to PowerShell in addition to the PowerShellGet module. If Install-Module cannot be found, and Import-Module PowerShellGet returns an error, [install WMF 5.1(https://www.microsoft.com/en-us/download/details.aspx?id=54616).
    'Posh-ACME', 'Posh-ACME.Deploy' | % { Install-Module $_ -Scope CurrentUser -Force }

Generate a new certificate

To generate the cert, you will need to create a new LetsEncrypt account for the host (LE does not recommend reusing accounts), request the new certificate, install it to the cert store, and install it to IIS. While this may sound like multiple steps, I've condensed them down into an example script that you can run on the client server, after modifying it for your environment:

$domain = ".mysite.example.com"
# NOTE: if your AD domain is not externally routable, modify or remove the
#       following default SAN, and update any references to it in later code
$sans = [System.Collections.ArrayList]@(('{0}.{1}' -f $env:COMPUTERNAME, $env:USERDNSDOMAIN).ToLower())
while ($san = Read-Host "Additional SAN") {
    $san = $san.ToLower()
    if (-not $san.EndsWith($domain)) {
        $san = '{0}{1}' -f $san, $domain
        if ((Read-Host "Domain does not match $domain, appended: $san`nContinue? [y/N]") -inotlike 'y*') { continue }
    }
    $sans.Add($san) >$null
}
if ((Read-Host "SANs: $($sans -join "`n      ")`nContinue? [y/N]") -ilike 'y*') {
    Set-PAServer LE_PROD
    $cert = New-PACertificate -Domain $sans -FriendlyName "LetsEncrypt $env:COMPUTERNAME" `
                              -AcceptTOS -Contact webmaster@mysite.example.com `
                              -Install -Plugin Windows -PluginArgs @{
      WinServer = 'mydnsserv'
      WinCred = New-Object System.Management.Automation.PSCredential (
        'mydnsserv\certuser', `
        (ConvertTo-SecureString -AsPlainText -Force 'mypassword')
      )
      WinUseSSL = $true
    }
    if ((Read-Host "Install to IIS Default Web Site on 443? [Y/n]") -ine 'n') {
        $cert | Set-IISCertificate -SiteName 'Default Web Site' -RemoveOldCert
    }
}

Set up automatic renewals

Lastly, to automate renewal, you need to set up a scheduled task on the client. Create a new blank task and assign the following options:

  • Let it run as the same user that you created the certificate with.
  • Tick "Run whether user is logged on or not".
  • Tick "Always run with highest privileges" unless you have configured the certificate store and IIS to be modifiable without such privileges. As mentioned, how to do so is outside the scope of the article.
  • Trigger should be daily; Posh-ACME will not reach out to LetsEncrypt until the certificate is close to expiring or already expired, so this is completely safe to do and will not get you rate limited. Alternatively, a weekly or even monthly trigger should work, as long as you configure the task to run after a missed trigger.
  • Run PowerShell as the task action: C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe

What arguments you enter for the action depends on your use-case.

  • For single-cert setups:
    -NoProfile -NoLogo -ExecutionPolicy Bypass -Command Submit-Renewal ('{0}.{1}' -f $env:COMPUTERNAME, $env:USERDNSDOMAIN).ToLower() | Set-IISCertificate -SiteName 'Default Web Site' -RemoveOldCert
  • For multi-site renewal:
    -NoProfile -NoLogo -ExecutionPolicy Bypass -Command $cert = Submit-Renewal ('{0}.{1}' -f $env:COMPUTERNAME, $env:USERDNSDOMAIN).ToLower(); if ($cert) {'site1', 'site2' | % {Set-IISCertificate $cert.Thumbprint -SiteName $_ -RemoveOldCert}}

If you want to run a script instead of shoving it into the arguments, replace the -Command parameter with -File, and point it at the desired file. Here is a more verbose starting point:

$san = ('{0}.{1}' -f $env:COMPUTERNAME, $env:USERDNSDOMAIN).ToLower()
$old = (Get-PACertificate $san).Thumbprint
$cert = Submit-Renewal $san -WarningAction SilentlyContinue
if ($cert) {
       # do crime here, remove $old if unnecessary
}

That’s about it. After following the above steps, you should have an automatically renewing LetsEncrypt certificate on your server. Once you do the process once, subsequent servers become much easier and faster to enroll.

Post-setup: Modify SAN list

In the event you need to update the SANs on the certificate, the Posh-ACME site provides a great tutorial:
https://poshac.me/docs/v4/Tutorial/#modifying-certificate-names

Left-click: follow link, Right-click: select node, Scroll: zoom
x