Request (and automatically renew) LetsEncrypt certificate using PowerShell and Route53 DNS service

ยท

8 min read

This post will show you how to request & renew free LetsEncrypt certificate using PowerShell in automated way.

As an addition, I will show you how you can use it for MailEnable SMTP server.

We will use:

  • MailEnable as a free SMTP server, but the certificate can be created the same way for other SMTP/Web servers too

  • PowerShell script to request/renew LetsEncrypt certificate using Posh-ACME module run through Scheduled task

  • LetsEncrypt DNS-01 challenge type for issuing the certificate

  • AWS Route53 DNS service, because it has API that allows you to scope permissions to modify just one specific DNS record

The result will be set & forget solution for managing LetsEncrypt certificates ๐Ÿ‘.


SMTP certificate requirements

  • The default LetsEncrypt certificate is just fine

  • It has to have a private key and an SMTP server service account (SYSTEM in the case of MailEnable) has to be able to read it

  • In the case of MailEnable SMTP server, it has to be placed in computer's personal certificate store


Creating AWS IAM user with permission to modify just LetsEncrypt DNS verification TXT record

Because we will use DNS challenge to request/renew the LetsEncrypt certificate, we need to use DNS provider that supports API access and set up some automation around it.

I've used this great post, which you can check to get more details.

In general, we need to create in the AWS console:

  • IAM user

  • User access key

  • IAM policy defining required permissions

Create AWS IAM user

Create an ordinary IAM user with default settings

Create a user Access key

Open newly created user Settings \ Security credentials \ Create access key

Select Third-party service type

Make a note of the newly created Access key and its Secret! We will need this later when setting up PowerShell automation.

Create AWS IAM policy

Now we will create an IAM policy that will allow our user to set and modify only selected TXT record (LetsEncrypt TXT record for satisfying the challenge).

Create a new policy

When on Specify permissions tab, switch setting to JSON.

Copy the following JSON and use it to replace the default one.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "route53:GetChange",
      "Resource": "arn:aws:route53:::change/*"
    },
    {
      "Effect": "Allow",
      "Action": "route53:ListHostedZonesByName",
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": ["route53:ListResourceRecordSets"],
      "Resource": ["arn:aws:route53:::hostedzone/Z11111112222222333333"]
    },
    {
      "Effect": "Allow",
      "Action": ["route53:ChangeResourceRecordSets"],
      "Resource": ["arn:aws:route53:::hostedzone/Z11111112222222333333"],
      "Condition": {
        "ForAllValues:StringEquals": {
          "route53:ChangeResourceRecordSetsNormalizedRecordNames": [
            "_acme-challenge.example.com"
          ],
          "route53:ChangeResourceRecordSetsRecordTypes": ["TXT"]
        }
      }
    }
  ]
}

Now we need to modify it a little bit!

Replace all Z11111112222222333333 with your hosted zone ID and example.com with a domain name for which you will create the certificate (for example smtp.contoso.com).

Zone ID can be found in Route 53 \ Hosted zones \ <the zone that will host the TXT record> \ Hosted zone details

Save the policy.

For more details check https://paulgalow.com/aws-route-53-iam-policy-letsencrypt-dns/

Attach IAM policy to the IAM user

Switch to Policies menu and filter your custom policies (Customer managed).

Pick the one you've created, select Entities attached and use Attach button to attach it to our IAM user.

The user now has the minimum possible permission to set the LetsEncrypt TXT challenge record.


Set up PowerShell script for managing certificate creation & renewal

๐Ÿ’ก
The following steps need to be done on the system where the certificate will be requested!

As stated earlier, the PSH script uses Posh-ACME module hence, we need to install it.

Install-Module "Posh-ACME"

Export Access key credential for making unattended AWS authentication

To be able to request and renew a LetsEncrypt certificate in the future, we must securely store AWS secret generated earlier.

Because DPAPI is used to store these credentials, the same account that will be used to run PSH script that requests and renews a LetsEncrypt certificate must be used to run the following code!

And it has to be a SYSTEM account because we need to install the requested certificate to the Computer Personal Certificate Store (because of MailEnable) and at the same time MailEnable server service account (which is a SYSTEM) must be able to read its private key (if you customize your MailEnable to run under different account, you can use any account with admin privileges to run this script).

Save following code to ps1 file and run it using Scheduled Task under SYSTEM account.

๐Ÿ’ก
Don't forget to modify $accessKey, $accessKeySecret, $xmlPath variables! $accessKey and $accessKeySecret corresponds to the AWS user access secret. $xmlPath is a path to XML, where this secret will be securely stored.
######## YOU NEED TO MODIFY THIS SECTION !!!
$accessKey = "<accessKey>"
$accessKeySecret = "<accessKeySecret>"
$xmlPath = "folderWhereAWSCredentialsAreStored\aws_api_credential.xml"
######## YOU NEED TO MODIFY THIS SECTION !!!

$securedAccessKeySecret = ConvertTo-SecureString $accessKeySecret -AsPlainText -Force
$credential = New-Object System.Management.Automation.PSCredential $accessKey, $securedAccessKeySecret
Export-Clixml -InputObject $credential -Path $xmlPath -Encoding UTF8 -Force -ea Stop

As a result you will get aws_api_credential.xml file with securely stored AWS credentials.

Create 'letsencrypt_cert.ps1' PSH script

Copy the following code and save it as letsencrypt_cert.ps1 file.

๐Ÿ’ก
Don't forget to modify $certDomainName, $expireContact, $cred variables to match your environment!
# run this script every day at random time
# lets encrypt certificates have 90 days validity and can be renewed 30 days before expiration, but when trying every day, you will be notified ASAP if some problem occurs

# https://poshac.me/docs/v4/Tutorial/#plugins
# https://paulgalow.com/aws-route-53-iam-policy-letsencrypt-dns/

$ErrorActionPreference = "Stop"


######## YOU NEED TO MODIFY THIS SECTION !!!
$certDomainName = "smtp.contoso.com"
$expireContact = "postmaster@contoso.com"
$cred = Import-Clixml "folderWhereAWSCredentialsAreStored\aws_api_credential.xml"

# uncomment if you want to change default location for Posh-ACME module config (by default %LOCALAPPDATA%\Posh-ACME)
# here are cached all orders, retrieved certificates, credentials (protected by DPAPI) etc!
# "Setting Posh-ACME config location"
# $env:POSHACME_HOME = "C:\folderWhereYouWantToStorePOSHACMEConfiguration"
# [Void][System.IO.Directory]::CreateDirectory($env:POSHACME_HOME)
######## YOU NEED TO MODIFY THIS SECTION !!!


Start-Transcript (Join-Path $PSScriptRoot ((Split-Path $PSCommandPath -Leaf) + ".log"))

Import-Module -Name "Posh-ACME"

Set-PAServer "LE_PROD" # LE_STAGE for testing purposes

if (!(Get-PAAccount -List)) {
    "No account exists, creating"
    New-PAAccount -Contact $expireContact -AcceptTOS
}

$pArgs = @{
    R53AccessKey = $cred.UserName
    R53SecretKey = $cred.Password
}

# check whether required certificate order exists
"Getting existing orders"
$PAOrder = Get-PAOrder -Name $certDomainName

if ($PAOrder) {
    # PluginArgs (credentials) are cached, but I want to always use the saved ones (in case of change)
    "Trying to renew"
    $renewed = Submit-Renewal -Name $certDomainName -PluginArgs $pArgs -Verbose

    if ($renewed) {
        "Renewal was successful"
        "Applying new certificate to MailEnable server"
        #region remove old certificate & restart the MailEnable service
        # if previously selected in MailEnable settings, certificate should be reused automatically if placed in the Personal cert. store again
        "Stopping MailEnable services"
        Stop-Service -DisplayName "MailEnable*"

        # remove old certificate
        "Removing old $certDomainName certificate from Personal Store"
        Get-ChildItem Cert:\LocalMachine\My | Where-Object { $_.FriendlyName -eq $certDomainName } | Remove-Item

        # install new certificate
        "Installing certificate to Personal store"
        Get-PACertificate -Name $certDomainName | Install-PACertificate -StoreLocation LocalMachine -StoreName My

        "Starting MailEnable services"
        Start-Service -DisplayName "MailEnable*"
        #endregion remove old certificate & restart the MailEnable service
    } else {
        return "No renewal needed"
    }
} else {
    # certificate order doesn't exist
    "Creating new certificate request"
    # PFX private key will be protected by first 15 chars of the API secret
    New-PACertificate -Name $certDomainName -Domain $certDomainName -AcceptTOS -Contact $expireContact -Plugin Route53 -PluginArgs $pArgs -PfxPass ($cred.GetNetworkCredential().password.substring(0, 15)) -Force -Verbose

    # install new certificate
    "Installing certificate to Personal store"
    Get-PACertificate -Name $certDomainName | Install-PACertificate -StoreLocation LocalMachine -StoreName My

    # as error to be notified that something has to be done to get this working fully
    throw "Certificate was created, but now you need to select it in the MailEnable configuration manually to finish the setup"
}

The code is based on the following official tutorial https://poshac.me/docs/v4/Tutorial/.

Create scheduled task to run 'letsencrypt_cert.ps1'

Now when we have ready aws_api_credential.xml file with AWS credentials and letsencrypt_cert.ps1 with an actual script, we need to automate the invocation of it.

Open Scheduled Task and create a new Scheduled Task as follows.

run as SYSTEM with admin privileges

run daily with 8 hours random delay

The random delay is to minimize the chance of LetsEncrypt service throttling.

run letsencrypt_cert.ps1 script

For the first time, run the scheduled task manually, to immediately get the requested certificate.

Invocation result will be logged in directory where letsencrypt_cert.ps1 script is located, named as letsencrypt_cert.ps1.log.

When the script finishes, you should see new certificate in the computer's personal certificate store, which means, everything is working as expected โค.


Set up MailEnable server to use our LetsEncrypt certificate

To be able to use the certificate with MailEnable SMTP server:

  • The certificate has to be placed in computer's personal certificate store

    • because renewal process creates a new one with each run, make sure you delete the expired one (my PSH script manages that)
  • The certificate has to contain a private key

  • MailEnable service account (SYSTEM by default) has to have the right to read our certificate private key

Now when the LetsEncrypt certificate exists, you have to open the MailEnable console and select it there

๐Ÿ’ก
It seems like even if you select NONE from the drop-down list, but the correct certificate exists, it will be automatically used anyway!

Restart the server, send a test email, and check MailEnable SMTP\Logs\Debug log. You should see something like this

Which mean that everything is working as it should!

Did you find this article valuable?

Support Do it PowerShell way :) by becoming a sponsor. Any amount is appreciated!

ย