Let's Encrypt with Azure Functions, PowerShell & Node.js

I really wanted to play with Azure Functions, especially since I found out it supported PowerShell. I needed a use case, and I found one.
A very dynamic environment with multi-region Azure Web Apps, bound to a ever-changing list of custom domains.

With these requirements, going the CloudFlare route or using the Let's Encrypt Site Extension wasn't possible.

The Problem

Generating a Let's Encrypt SAN SSL Certificate in a very dynamic multi-region environment.

The Solution

Using Azure Functions, perform the various steps needed to get a Let's Encrypt certificate.
Once obtained, upload to the various Azure App Services and bind for the given custom hostnames.
Automate the process to deal with changes to Web App deployments and domain names.

The How

ACME Client

First I needed an ACME client. The most reasonable choice was ACMESharp. But this failed as the PowerShell module didn't work in my the Azure Function.
Something I did find out though was that you can use PowerShell modules in Azure Functions.
FTP is a pain though, rather use the Kudu console so you can drag and drop modules.

Next up was ACMESharp Core. Which also didn't work out because I couldn't generate a password-protected PFX file. I'm building a POC so modifying libraries wasn't on the to-do list.

Thankfully there are many ways to skin a cat. Greenlock-cli came to the rescue. A Node.js based cli ACME client.


ACME Challenge

At first I used a Azure Storage Queue Trigger function for this but if something goes wrong, the message doesn't get popped and you get stuck in a loop, eventually exceeding the Let's Encrypt rate limits so I switched to a HTTP Trigger Function.

Since I'm dealing with domains for which I can't create DNS records, I'm doing HTTP validation.
In my ASP.NET app, I have an attribute route [Route(".well-known/acme-challenge/{challenge}")]
When hit, I get the challenge response from blob storage for that domain name and return it.

The --manual switch on greenlock-cli, means to wait for user confirmation before validating the challenge. Also note, the snippet below has the Let's Encrypt staging environment.

Weirdly environment variables don't apply to cmd when invoked from PS, so I had to install greenlock-cli from the specific NPM version folder and execute it from %AppData%.

# Get posted string list of domains from request body and convert to PS array object
$domains = Get-Content $req -Raw | ConvertFrom-Json

$env:PSModulePath = 'D:\home\site\wwwroot\PowerShellModules\';
Write-Output "Loading Azure Module"
Import-Module -Name D:\home\site\wwwroot\PowerShellModules\AzureRM.Storage -Verbose
# Token stored in Application settings
$sasToken = Get-ChildItem Env:SASToken | Select-Object -ExpandProperty Value
$StorageContext = New-AzureStorageContext 'You Storage Account Name' -SasToken $sasToken

# Install greenlock-cli and store cmd output in a text file
$greenlock = cmd /c "cd D:\Program Files (x86)\npm\5.6.0\ && npm install -g greenlock-cli > D:\home\site\wwwroot\NewDomainGreenlockLog.txt 2>&1"

# BOM encoding will give us invalid JSON
$Utf8NoBomEncoding = New-Object System.Text.UTF8Encoding $False

# Get challenge response for HTTP validation, store in a text file then upload to blob # storage
Foreach ($domain in $domains)
{
cmd /c "cd D:\local\AppData\npm\ && greenlock certonly --manual \ --acme-version draft-11 --acme-url https://acme-staging-v02.api.letsencrypt.org/directory \ --agree-tos --email dev@lionelchetty.co.za--domains $($domain) \ --community-member \ --config-dir D:\home\site\wwwroot\ACME\ > D:\home\site\wwwroot\ACMEOriginal\$($domain).txt 2>&1"

$json = [IO.File]::ReadAllText("D:\home\site\wwwroot\ACMEOriginal\$($domain).txt")
$acme=$json.Substring($json.IndexOf("{"), $json.IndexOf("}")-$json.IndexOf("{")+1)
Set-AzureStorageBlobContent -File D:\home\site\wwwroot\ACMEChallenge\$($domain).json -Container 'Your Container' -Context $StorageContext -Blob "$($domain).json" -Force
}

$message = $domains | ConvertTo-Json
# Trigger next function in the process
$DoneResponse = Invoke-RestMethod -Uri 'https://fxapp.azurewebsites.net/api/FX-New_Challenge' -Body $message -ContentType "application/json" -Method 'Post'

ACME Validation

This is a C# based function, because greenlock-cli needs user input to perform the validation aspect.

In this snippet of code, I'm taking the list of domains and joining them into a comma separated string.
After each domain validation I'm waiting 2.5s then pressing 'enter'.
You can interact with cmd through PS but that didn't work for me so I went the C# route.

After I process every domain in the list, I trigger the next function with the first domain name as the parameter.

using System.Net;
using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;

public static async Task<HttpResponseMessage> Run(HttpRequestMessage req, TraceWriter log)
{
log.Info("C# HTTP trigger function processed a request.");
var domainList = await req.Content.ReadAsAsync<List<string>>();

await Validate(domainList);
using (var httpClient = new HttpClient())
{
await httpClient.GetAsync($"https://fxapp.azurewebsites.net/api/FX-New_PFX    
}
return req.CreateResponse(HttpStatusCode.OK, domainList[0]);
}

private async static Task Validate(List<string> domainList)
{
var domains = string.Join(",",domainList);
System.Diagnostics.Process myProcess = new System.Diagnostics.Process();
myProcess.StartInfo.FileName = @"D:\Windows\System32\cmd.exe";
myProcess.StartInfo.Arguments = $@"/c cd D:\local\AppData\npm\ && greenlock certonly \ --acme-version draft-11 --acme-url https://acme-staging-v02.api.letsencrypt.org/directory \ --agree-tos --email dev@lionelchetty.co.za --domains {domains} \ --community-member \ --config-dir D:\home\site\wwwroot\ACME\ > D:\home\site\wwwroot\NewChallengeGreenlockLog.txt 2>&1";

myProcess.StartInfo.UseShellExecute = false;
myProcess.StartInfo.RedirectStandardInput = true;
myProcess.StartInfo.RedirectStandardError = true;
myProcess.StartInfo.RedirectStandardOutput = true;
myProcess.Start();

System.IO.StreamWriter myStreamWriter = myProcess.StandardInput;
foreach(var item in domainList){
myStreamWriter.WriteLine(" "); 
Thread.Sleep(2500);
}

myStreamWriter.WriteLine(" "); 
myProcess.WaitForExit();
}

Pfx Certificate Creation

If all went according to plan, you should have all the .pem files.
I used OpenSSL from ACMESharp to create my .pfx file.
I combine the private key with the full chain certificate and also add in my super secure password.
Also I upload everything to blob storage for back-up purposes.

Lastly I make a WebHook call to my website to upload the certificate to Azure,
I could have done this in a function but I didn't want my Azure details all over the place.

$domain = $req_query_domain
$TimestampForBlobFolder = (Get-Date).ToString("yyyy.MM.dd")
$TimestampForPfxName = (Get-Date).ToString("yyyy-MM-dd-hh-mm-ss")

Invoke-Expression -Command "cd D:\home\site\wwwroot\ACME\archive\$domain"
Invoke-Expression -Command "D:\home\site\wwwroot\OpenSSL\openssl pkcs12 -export -inkey             privkey0.pem -in fullchain0.pem -out $TimestampForPfxName.pfx -nodes -password pass:SomeSecurePassword"

$env:PSModulePath = 'D:\home\site\wwwroot\PowerShellModules\';
Write-Output "Loading Azure Module"
Import-Module -Name D:\home\site\wwwroot\PowerShellModules\AzureRM.Storage -Verbose
$sasToken = Get-ChildItem Env:SASToken | Select-Object -ExpandProperty Value
$StorageContext = New-AzureStorageContext 'You Storage Account Name' -SasToken $sasToken

$sourceFileRootDirectory = "D:\home\site\wwwroot\ACME\archive\$domain\"
$checkForPfx= Get-ChildItem -Path $sourceFileRootDirectory -Recurse -Filter "*.pfx"
if($checkForPfx.count -gt 0){
$filesToUpload = Get-ChildItem $sourceFileRootDirectory -Recurse -File
foreach ($x in $filesToUpload) {
$targetPath = ($x.fullname.Substring($sourceFileRootDirectory.Length + 1)).Replace("\", "/")
Write-Verbose "Uploading $("\" + $x.fullname.Substring($sourceFileRootDirectory.Length + 1)) to $($container.CloudBlobContainer.Uri.AbsoluteUri + "/" + $targetPath)"
Set-AzureStorageBlobContent -File $x.fullname -Container "lets-encrypt" -Blob "$TimestampForBlobFolder\$domain\$x" -Context $StorageContext -Force:$Force | Out-Null
}

$message= "$TimestampForBlobFolder\$domain\$TimestampForPfxName.pfx" | ConvertTo-Json
$WebHookCall = Invoke-RestMethod -Uri 'website webhook' -Body $message -ContentType "application/json" -Method 'Post'

Upload Certificate to Azure

I use the Azure Resource Manager REST APIs for most of my integration work on Azure, I consume the endpoints as needed. Below is a sample of my code.

The snippet below creates the new Web App Certificate request, you need to encode the certificate as Base64.
This needs to be done for each location/region you have App Services in.

var webAppsCertificate = new WebAppsCertificateResponse();
webAppsCertificate.properties = new WebAppsCertificateProperties();
var blob = await AzureStorageHelper.GetFileFromBlob(
                    certificateBlobPath, "lets-encrypt");
webAppsCertificate.properties.pfxBlob = System.Convert.ToBase64String(blob.ToArray());
webAppsCertificate.properties.password = "SomeSecurePassword";
webAppsCertificate.name = DateTime.UtcNow.ToString("yyyymmddhhmmss");
webAppsCertificate.properties.friendlyName = webAppsCertificate.name;
webAppsCertificate.location = item.Key;

The snippet below binds the certificate to the hostnames contained in it, to the hostnames that match in the Web App.

foreach (var app in webApps)
{
var coreWebAppDetails = await azureClient.WebApps.GetDetails(app.id);
foreach (var certificateHostName in createCertificate.properties.hostNames)
{
var sslHostNameState = coreWebAppDetails.properties.hostNameSslStates.FirstOrDefault(wa => string.Equals(wa.name, certificateHostName, StringComparison.OrdinalIgnoreCase));
if (sslHostNameState != null && sslHostNameState.sslState==0)
{
sslHostNameState.toUpdate = true;
sslHostNameState.sslState = 1;
sslHostNameState.thumbprint = createCertificate.properties.thumbprint;
}
}

try
{
var updateDetails = await azureClient.WebApps.UpdateDetails(coreWebAppDetails, app.id);
Logger.Info($"Add SSL Cert And Create Bindings: Added SSL for {app.name}");
}
catch (Exception updateException)
{
Logger.Info($"Add SSL Cert And Create Bindings: Update WebApp Details Exception");
Logger.Error(updateException);
}
}