Lcs 1184 auto renewal for ssl certs and enable ssl health check - #321
mrlockstar wants to merge 2 commits into
Conversation
f9adf4c to
77a552e
Compare
The SSL certificates are semi managed in Front door and you can renew the certificates via the console; however, this does not happen automatically for the apec domain and instead a manual check needs to occur. Please note that this script is triggered off the ADO pipeline and uses the MI that is used to deploy the terraform in Azure.
77a552e to
f184524
Compare
| #!/bin/bash | ||
|
|
||
| ################################################################################ | ||
| # Azure Front Door SSL Certificate Renewal - Process Flow | ||
| ################################################################################ | ||
| # | ||
| # 1. Receive the Azure subscription, target domain and certificate expiry | ||
| # threshold as command-line arguments. | ||
| # | ||
| # 2. Find the Azure Front Door profile containing the requested custom domain. | ||
| # | ||
| # 3. Read the domain's current Front Door configuration and validation state. | ||
| # | ||
| # 4. Connect to the target domain over HTTPS and determine the expiry date | ||
| # of the TLS certificate currently being served. | ||
| # | ||
| # 5. Decide whether certificate renewal/revalidation is required: | ||
| # | ||
| # - If forced with -f: | ||
| # Regenerate the Front Door validation token. | ||
| # | ||
| # - If Front Door is in PendingRevalidation or InternalError: | ||
| # Regenerate the validation token. | ||
| # | ||
| # - If the currently served TLS certificate expires within the configured | ||
| # threshold: | ||
| # Regenerate the Front Door validation token. | ||
| # | ||
| # - Otherwise: | ||
| # Make no changes and finish. | ||
| # | ||
| # 6. When regeneration is required: | ||
| # - Request a new Front Door validation token. | ||
| # - Wait for Front Door to expose the refreshed token. | ||
| # - Retrieve the new token and current validation state. | ||
| # | ||
| # 7. If DNS validation is required: | ||
| # - Locate the Azure DNS zone for the domain. | ||
| # - Determine the required _dnsauth TXT record. | ||
| # - Check whether the TXT record already exists. | ||
| # - Create the record if it does not exist. | ||
| # - Update it if the existing token differs from the Front Door token. | ||
| # - Leave it unchanged if it already contains the correct token. | ||
| # | ||
| # 8. If Front Door validation is Pending or PendingRevalidation: | ||
| # - Wait for the validation state to become Approved. | ||
| # - Fail if Front Door enters a terminal error state or the timeout | ||
| # is reached. | ||
| # | ||
| # 9. Finish after the domain is validated or when no renewal/revalidation | ||
| # was required. | ||
| # | ||
| # IMPORTANT: | ||
| # The renewal decision is based on the expiry of the TLS certificate | ||
| # currently served by the domain. The validation-token expiry alone does | ||
| # not trigger certificate regeneration. | ||
| # | ||
| ################################################################################ | ||
|
|
||
|
|
||
| set -euo pipefail | ||
|
|
||
| TODAY=$(TZ=Europe/London date -Idate) | ||
|
|
||
| ################################################################################ | ||
| # Parse command-line arguments | ||
| ################################################################################ | ||
|
|
||
| usage() { | ||
| echo "Usage: $0 -s <subscription> -d <target-domain> -e <expiration-days> [-r <resource-group>]" | ||
| echo | ||
| echo "Required arguments:" | ||
| echo " -s Azure subscription name or ID" | ||
| echo " -d Target domain" | ||
| echo " -e TLS certificate expiration threshold in days" | ||
| echo | ||
| echo "Optional arguments:" | ||
| echo " -f Force regeneration of TLS certificate (optional)" | ||
| echo " -r Resource group filter" | ||
| echo | ||
| echo "Example:" | ||
| echo " $0 -s my-subscription -d example.nhs.uk -e 30" | ||
| echo " $0 -s my-subscription -d example.nhs.uk -e 30 -f" | ||
| exit 1 | ||
| } | ||
|
|
||
| AZ_SUBSCRIPTION_SCOPE="" | ||
| TARGET_DOMAIN="" | ||
| AFD_DOMAIN_EXPIRATION_DAYS="" | ||
| RESOURCE_GROUP_FILTER="" | ||
| FORCE_REGENERATION=false | ||
|
|
||
| while getopts ":s:d:e:fr:h" opt; do | ||
| case "$opt" in | ||
| s) | ||
| AZ_SUBSCRIPTION_SCOPE="$OPTARG" | ||
| ;; | ||
| d) | ||
| TARGET_DOMAIN="$OPTARG" | ||
| ;; | ||
| e) | ||
| AFD_DOMAIN_EXPIRATION_DAYS="$OPTARG" | ||
| ;; | ||
| f) | ||
| FORCE_REGENERATION=true | ||
| ;; | ||
| r) | ||
| RESOURCE_GROUP_FILTER="$OPTARG" | ||
| ;; | ||
| h) | ||
| usage | ||
| ;; | ||
| :) | ||
| echo "ERROR: Option -$OPTARG requires an argument." >&2 | ||
| usage | ||
| ;; | ||
| \?) | ||
| echo "ERROR: Invalid option: -$OPTARG" >&2 | ||
| usage | ||
| ;; | ||
| esac | ||
| done | ||
|
|
||
| ################################################################################ | ||
| # Validate configuration | ||
| ################################################################################ | ||
|
|
||
| if [[ -z "$AZ_SUBSCRIPTION_SCOPE" ]]; then | ||
| echo "ERROR: Azure subscription is required. Use -s <subscription>." >&2 | ||
| usage | ||
| fi | ||
|
|
||
| if [[ -z "$TARGET_DOMAIN" ]]; then | ||
| echo "ERROR: Target domain is required. Use -d <domain>." >&2 | ||
| usage | ||
| fi | ||
|
|
||
| if [[ -z "$AFD_DOMAIN_EXPIRATION_DAYS" ]]; then | ||
| echo "ERROR: Expiration days is required. Use -e <days>." >&2 | ||
| usage | ||
| fi | ||
|
|
||
| if ! [[ "$AFD_DOMAIN_EXPIRATION_DAYS" =~ ^[0-9]+$ ]]; then | ||
| echo "ERROR: Expiration days must be a positive integer." >&2 | ||
| echo "Value supplied: $AFD_DOMAIN_EXPIRATION_DAYS" >&2 | ||
| exit 1 | ||
| fi | ||
|
|
||
| echo "TLS certificate expiration threshold: $AFD_DOMAIN_EXPIRATION_DAYS days" | ||
| echo "Target domain: $TARGET_DOMAIN" | ||
| echo "Azure subscription: $AZ_SUBSCRIPTION_SCOPE" | ||
|
|
||
| if [[ -n "$RESOURCE_GROUP_FILTER" ]]; then | ||
| echo "Resource group filter: $RESOURCE_GROUP_FILTER" | ||
| fi | ||
|
|
||
| echo | ||
|
|
||
| ################################################################################ | ||
| # Find Azure Front Door profiles | ||
| ################################################################################ | ||
|
|
||
| echo "Looking for Azure Front Door CDNs..." | ||
|
|
||
| AFD_LIST=$( | ||
| az afd profile list \ | ||
| --only-show-errors \ | ||
| --subscription "$AZ_SUBSCRIPTION_SCOPE" | | ||
| jq -rc '.[] | { | ||
| "name": .name, | ||
| "resourceGroup": .resourceGroup | ||
| }' | ||
| ) | ||
|
|
||
| AFD_COUNT=$(echo "$AFD_LIST" | wc -l | tr -d ' ') | ||
|
|
||
| echo "Found $AFD_COUNT Azure Front Door(s) total" | ||
| echo | ||
|
|
||
| if [[ -n "$RESOURCE_GROUP_FILTER" ]]; then | ||
| echo "Filtering for resource group: $RESOURCE_GROUP_FILTER" | ||
| echo | ||
| fi | ||
|
|
||
| ################################################################################ | ||
| # Find the Azure Front Door containing TARGET_DOMAIN | ||
| ################################################################################ | ||
|
|
||
| TARGET_AFD="" | ||
| TARGET_RESOURCE_GROUP="" | ||
| MATCHING_DOMAIN="" | ||
|
|
||
| for AZURE_FRONT_DOOR in $AFD_LIST; do |
There was a problem hiding this comment.
Would it not be better to use something like
while IFS= read -r AZURE_FRONT_DOOR; do ... done <<< "$AFD_LIST"
given that it looks like AFD_LIST contains compact JSON objects?
There was a problem hiding this comment.
Happy to try that
| echo "FORCE regeneration enabled." | ||
| echo "Validation token will be regenerated regardless of certificate expiry or validation state." | ||
|
|
||
| elif [[ "$STATE" == "PendingRevalidation" || "$STATE" == "InternalError" ]]; then |
There was a problem hiding this comment.
I wonder if one should also include "$STATE" == "TimedOut" since it's an API state (https://learn.microsoft.com/en-us/rest/api/frontdoorservice/azurefrontdoorstandardpremium/afd-custom-domains/get?view=rest-frontdoorservice-azurefrontdoorstandardpremium-2025-04-15)
| echo "Checking currently served TLS certificate..." | ||
|
|
||
| CERT_END_DATE=$( | ||
| echo | openssl s_client \ |
There was a problem hiding this comment.
If we run this script in an unattended mode, then we'll need some kind of timeout for requests that take too long. So possibly one could add timeout value or similar?
|



Description
This was based on a script from another project; however, it did not work the exact way we wanted it to so it was modified via AI and tested using the lungcs environment.
It was used to renew both the preprod and prod certificate (there was no risk to live service).
https://github.com/DFE-Digital/teacher-services-cloud/blob/main/.github/workflows/afd-domain-renewal.yml
Evidence if it working can be seen here: -
https://dev.azure.com/nhse-dtos/lung-cancer-screening/_build?definitionId=142&_a=summary
preprod
https://dev.azure.com/nhse-dtos/lung-cancer-screening/_build/results?buildId=46397&view=results
prod
https://dev.azure.com/nhse-dtos/lung-cancer-screening/_build/results?buildId=46388&view=results
Context
Type of changes
Checklist
Sensitive Information Declaration
To ensure the utmost confidentiality and protect your and others privacy, we kindly ask you to NOT including PII (Personal Identifiable Information) / PID (Personal Identifiable Data) or any other sensitive data in this PR (Pull Request) and the codebase changes. We will remove any PR that do contain any sensitive information. We really appreciate your cooperation in this matter.