ACME brings a high level of convenience for issuing certificates for cloud services (global & local gateways, web services).
That works pretty fine in many variations, but what if
- I don't have an actual server where I can host some certbot / nginx magic to handle a HTTP-01 challenge - see challenge types
- I do not want to spoil an infrastructure and pass down a request to the bowels of my business compute see sample architecture
- I'm not able to maintain DNS entries via automation for a DNS-01 challenge
and I want to automate anyway?
This post describes how to
- build and bring up a container based on certbot & nginx in ACR - Azure Container Registry and ACI - Azure Container Instances
- wire up this container with an Application Gateway instance
What is required
- an existing Application Gateway which currently routes traffic and which is bound (custom DNS) to the domain, for which the certificate is to be requested - having no handling for port 80 / HTTP so the route to the container instance can be injected
- or (as in my case) an existing Front Door from which the HTTP / port 80 traffic is routed to a temporary / dedicated Application Gateway
What I use
- Azure CLI 2.15.1 to handle Azure resources
- PowerShell Core 7.x to drive the process - because I like the primitives for handling JSON emitted by Azure CLI - but this can be easily converted to bash / jq
- a Storage Account mounted to ACI to capture certbot output
- an ACR to build and provide the Docker image
- a Service Principal to allow ACI to pull image from ACR - I would have preferred Managed Identity but there is currently a limitation
- nginx to handle incoming web traffic for HTTP-01 challenge
- certbot to drive ACME process
Structure
Script snippets provided in this post follow this structure
- build up permanent components (ACR, Service Principal, create Storage, create Application Gateway HTTP route)
- build up temporary components (renew Service Principal credentials, build image, create ACI, establish Application Gateway to ACI link)
- execute certificate request
- destroy temporary components (see 2. - but reverse)
assuming between steps 1 and 2, after a certificate has been initially created, some time passes until certificate is to be renewed.
Generally all components could be created and wired up temporarily - which would make it a even more secure and kind of stateless approach. However while building and trying I collected these topics to consider:
a) deploying a temporary dedicated Application Gateway with all the settings can easily take 20-30 minutes
b) in my case - connecting this temporary Application Gateway to Front Door also can take another 20-30 minutes, until configuration really is effective
c) in one version I created Service Principal temporarily; just because I ended up in a lot of iterations I splitted this process into a creation and renewal of credentials
d) Storage also can be handled temporarily - just a matter of a few minutes of deployment time - but then with a GUID like name to guarantee uniqueness
0. common variables and initialization
I did not spend any energy in abstration of commonly used configuration information - hence I have these variables copied at the start of each of the scripts:
$location = "{your-location}"
$resourceGroupName = "{your-resourcegroup}"
$appGwName = "myacmepoc" # name of the Application Gateway to create / manage
$containerGroupName = "myacmepoc" # name of the Azure Container Instance to be created
$publicIpName = "myacmepoc" # name of Public IP used for Application Gateway
$registryName = "myacmepoc" # name of Azure Container registry
$servicePrincipalName = "myacmepoc" # name of Service Principal to be used to pull from ACR to ACI
$storageAccountName = "myacmepoc" # name of Storage Account where certificates are captured (from ACI)
$storageAccountShareName = "certificates" # name of share on Storage Account to be mounted to ACI
$subscriptionId = "{your-subscription-id}"
az account set -s $subscriptionId
az configure -d location=$location group=$resourceGroupName
1. build up permanent components
Container Registry with a Service Principal
az acr create -n $registryName --sku Basic
$registryResourceId = $(az acr show --name $registryName --query id --output tsv)
az ad sp create-for-rbac --name $servicePrincipalName --scopes $registryResourceId --role acrpull
dedicated Application Gateway
SKIP this section when you're working with an existing Application Gateway, which is already used for your backend exposure
Create a basic setup with HTTPS which allows modification of HTTP configuration without breaking the gateway.
PFX path/password - in my case I already had an existing certificate which I could bind to the HTTPS configuration
az network application-gateway create `
--name $appGwName `
--capacity 1 `
--sku Standard_v2 `
--http-settings-cookie-based-affinity Enabled `
--public-ip-address $publicIpName
## - HTTPS configuration
az network application-gateway http-settings create `
-n appGatewayBackendHttpsSettings `
--gateway-name $appGwName `
--port 443 --protocol Https --cookie-based-affinity Enabled
az network application-gateway ssl-cert create `
-n httpsCert `
--gateway-name $appGwName `
--cert-file $(Read-Host "PFX path") `
--cert-password $(Read-Host "PFX password")
az network application-gateway frontend-port create `
-n appGatewayHttpsPort `
--gateway-name $appGwName `
--port 443
az network application-gateway http-listener create `
-n appGatewayHttpsListener `
--gateway-name $appGwName `
--frontend-ip appGatewayFrontendIP `
--frontend-port appGatewayHttpsPort `
--ssl-cert httpsCert
az network application-gateway rule create `
-n ruleHttps `
--gateway-name $appGwName `
--address-pool appGatewayBackendPool `
--http-listener appGatewayHttpsListener `
--http-settings appGatewayBackendHttpsSettings
## - HTTP configuration - clean-up
az network application-gateway rule delete `
-n rule1 `
--gateway-name $appGwName
az network application-gateway http-settings delete `
-n appGatewayBackendHttpSettings `
--gateway-name $appGwName
add HTTP configuration to gateway
Create the HTTP configuration with a dummy backend - which will be set to the address of running ACI container later. Add a probe so that health can be checked.
## - HTTP configuration - build-up
az network application-gateway probe create `
-n probeContainer `
--gateway-name $appGwName `
--path "/index.html" --protocol Http `
--host-name-from-http-settings true
az network application-gateway address-pool create `
-n appGatewayBackendContainerPool `
--gateway-name $appGwName `
--servers 10.0.0.1
az network application-gateway http-settings create `
-n appGatewayBackendHttpSettings `
--gateway-name $appGwName `
--probe probeContainer `
--port 80 `
--host-name-from-backend-pool true
az network application-gateway rule create `
-n ruleContainer `
--gateway-name $appGwName `
--address-pool appGatewayBackendContainerPool `
--http-listener appGatewayHttpListener `
--http-settings appGatewayBackendHttpSettings
(optional) link to Front Door
At this step I would link the exposed public IP on port 80 to Front Door.
2. build up temporary components
initialization
Initialize some values for the certificate generation process. My provider works with External Account Binding - hence these additional credential information.
$downloadFolder = "./certificates"
$domain = "webservice.mydomain.com"
$emailAddress = "my.email@mydomain.com"
$serverUrl = "https://{providers-ACME-directory-url}"
$eabkid = "{external-account-binding-key-identifier}"
$eabhmac = "{HMAC-key-for-external-account-binding}"
[Console]::ResetColor()
comes in handy in case console colors are messed up from a previous failed run
I like to specifically tag those kind of images just to be absolutely sure that the correct version is pulled and used.
[Console]::ResetColor()
$registry = az acr show -n $registryName -o json | ConvertFrom-Json
$tag = Get-Date -AsUTC -Format yyMMdd_HHmmss
$image = "$($registry.loginServer)/certbot:$tag"
$storageAccountKey = $(az storage account keys list --account-name $storageAccountName --query "[0].value" --output tsv)
az storage share create --name $storageAccountShareName --account-name $storageAccountName
Renew credentials on Service Principal so that I do not need to store those somewhere.
$servicePrincipal = az ad sp credential reset --name $servicePrincipalName -o json | ConvertFrom-Json
$delay = 30
Write-Host "wait $delay seconds for service principal credential change"
Start-Sleep -Seconds $delay
build and spin up container
Build container on ACR (no local Docker required) and spin up in ACI. With that mount Storage Account share to the container.
az acr build -t $image -r $registryName .
az container create --name $containerGroupName `
--image $image `
--registry-login-server $registry.loginServer --registry-username $credentials.username --registry-password $credentials.passwords[0].value `
--ip-address public `
--azure-file-volume-account-name $storageAccountName `
--azure-file-volume-account-key $storageAccountKey `
--azure-file-volume-share-name $storageAccountShareName `
--azure-file-volume-mount-path "/tmp/certificates" `
--environment-variables DOMAIN=esb-dev.$dnsSuffix `
--secure-environment-variables EMAIL=$EmailAddress SERVERURL=$ServerUrl EABKID=$EABKID EABHMACKEY=$EABHMACKEY
$containerGroup = az container show -n $containerGroupName -o json | ConvertFrom-Json
Sample configuration for Docker and nginx is based on this post.
Dockerfile
FROM nginx:alpine
RUN apk add python3 python3-dev py3-pip build-base libressl-dev musl-dev libffi-dev
RUN apk add gcc musl-dev openssl-dev cargo
RUN pip3 install pip --upgrade
RUN pip3 install certbot-nginx
RUN mkdir /etc/letsencrypt
COPY default.conf /etc/nginx/conf.d/default.conf
COPY challenge.sh /root/challenge.sh
RUN chmod u+x /root/challenge.sh
challenge.sh
in an earlier version of this post, the sequence of commands was executed directly over
az container exec
. However when revisiting the scenario in summer 2021, this direct execution was not possible anymore - see my StackOverflow post.
##!/bin/sh
rm -rf /tmp/certificates/*
certbot register --email $EMAIL --server $SERVERURL --eab-kid $EABKID --eab-hmac-key $EABHMACKEY --agree-tos -n
certbot certonly --nginx --email $EMAIL --server $SERVERURL -d $DOMAIN
cp -avr /etc/letsencrypt /tmp/certificates
cp /var/log/letsencrypt/letsencrypt.log /tmp/certificates/
default.conf
server {
listen 80;
server_name _;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
modify Application Gateway and retrieve health
az network application-gateway address-pool update `
-n appGatewayBackendContainerPool `
--gateway-name $appGwName `
--set "backendAddresses[0].ipAddress=$($containerGroup.ipAddress.ip)"
$health = az network application-gateway show-backend-health -n $appGwName -o json | ConvertFrom-Json
$serverHealth = $health.backendAddressPools | ?{$_.backendAddressPool.id -match "appGatewayBackendContainerPool"} | %{$_.backendHttpSettingsCollection[0].servers}
$serverHealth
3. execute certificate request
When Application Gateway seems healthy, execute ACME server registration and certificate creation. Backup whole folder used during the process onto the Storage Account share. These folder is then downloaded locally.
In this sample, container group on ACI is started with a public IP address. This is also possible with linking the container group to a virtual network with a private IP address. However the machine driving the process and submitting
az container exec
also needs to be linked to this virtual network then.
if($serverHealth[0].health -eq "Healthy") {
"=" * 80
Read-Host "Hit enter to start certificate challenge"
az container exec -n $containerGroupName --exec-command "/root/challenge.sh"
"=" * 80
Read-Host "Hit enter to download"
if (Test-Path $downloadFolder -PathType Container) {
Remove-Item $downloadFolder -Recurse -Force
}
New-Item $downloadFolder -ItemType Directory
az storage file download-batch --account-name $storageAccountName --account-key $storageAccountKey -s $storageAccountShareName -d $downloadFolder
}
4. destroy temporary components
Read-Host "Hit enter to clean up"
az container stop -n $containerGroupName
az container delete -n $containerGroupName --yes
az storage share delete --name $storageAccountShareName --account-name $storageAccountName
variations
When working with a dedicated Application Gateway it is possible to start & stop it at the beginning and the end of the process.
az network application-gateway start --name $appGwName
az network application-gateway stop --name $appGwName
conclusion
I know, the depicted configuration might look very specific and some may wonder "Why all this hassle when there are services like Let's Encrypt?". However I hope that there is worthwhile information or some insights for you people out there. Please let me know what you think or where I could improve.
Top comments (0)