GithubHelp home page GithubHelp logo

arnavion / acme-azure-function Goto Github PK

View Code? Open in Web Editor NEW
2.0 3.0 0.0 498 KB

Azure Function that auto-renews TLS certificates using ACME v2

License: GNU Affero General Public License v3.0

Shell 9.87% Makefile 0.30% Rust 89.83%

acme-azure-function's Introduction

This repository contains an Azure Function that monitors a certificate in an Azure KeyVault and renews it before it expires using the ACME v2 protocol. The Function is implemented in Rust and runs as a custom handler.

This Function is used for the HTTPS certificate of https://www.arnavion.dev using Let's Encrypt as the ACME server.

Build dependencies

  • azure-cli
  • curl
  • gojq or jq
  • openssl
  • podman

Setup

  1. Define some global variables.

    # The top-level domain name. Certificates will be requested for "*.TOP_LEVEL_DOMAIN_NAME".
    export TOP_LEVEL_DOMAIN_NAME='arnavion.dev'
    
    # The resource group that will host the KeyVault and DNS zone.
    export AZURE_COMMON_RESOURCE_GROUP_NAME='arnavion-dev'
    
    # The ACME server's directory URL.
    export ACME_DIRECTORY_URL='https://acme-v02.api.letsencrypt.org/directory'
    
    # The contact URL used to register an account with the ACME server.
    export ACME_CONTACT_URL='mailto:[email protected]'
    
    # The resource group that will host the Function app.
    export AZURE_ACME_RESOURCE_GROUP_NAME='arnavion-dev-acme'
    
    # The name of the KeyVault.
    export AZURE_KEY_VAULT_NAME='arnavion-dev'
    
    # The name of the Function app.
    export AZURE_ACME_FUNCTION_APP_NAME='arnavion-dev-acme'
    
    # The name of the Storage Account used by the Function app.
    export AZURE_ACME_STORAGE_ACCOUNT_NAME='arnaviondevacme'
    
    # The name of the KeyVault key that will store the ACME account key.
    export AZURE_KEY_VAULT_ACME_ACCOUNT_KEY_NAME='letsencrypt-account-key'
    
    # The name of the Azure KeyVault certificate
    export AZURE_KEY_VAULT_CERTIFICATE_NAME='star-arnavion-dev'
    
    # The resource group that will host the Log Analytics workspace.
    export AZURE_LOG_ANALYTICS_WORKSPACE_RESOURCE_GROUP_NAME='logs'
    
    # The Log Analytics workspace.
    export AZURE_LOG_ANALYTICS_WORKSPACE_NAME='arnavion-log-analytics'
    
    # The name of the Azure role used for the Function app.
    export AZURE_ACME_ROLE_NAME='functionapp-acme'
    
    
    export AZURE_ACCOUNT="$(az account show)"
    export AZURE_SUBSCRIPTION_ID="$(<<< "$AZURE_ACCOUNT" jq --raw-output '.id')"
  2. Deploy Azure resources.

    # Create the resource group for the KeyVault and DNS zone.
    az group create --name "$AZURE_COMMON_RESOURCE_GROUP_NAME"
    
    
    # Create a KeyVault.
    az keyvault create \
        --resource-group "$AZURE_COMMON_RESOURCE_GROUP_NAME" --name "$AZURE_KEY_VAULT_NAME" \
        --enable-rbac-authorization
    
    
    # Create a DNS zone.
    az network dns zone create \
        --resource-group "$AZURE_COMMON_RESOURCE_GROUP_NAME" --name "$TOP_LEVEL_DOMAIN_NAME"
    
    
    # Create the resource group for the Function app.
    az group create --name "$AZURE_ACME_RESOURCE_GROUP_NAME"
    
    
    # Create a Storage account for the Function app.
    az storage account create \
        --resource-group "$AZURE_ACME_RESOURCE_GROUP_NAME" --name "$AZURE_ACME_STORAGE_ACCOUNT_NAME" \
        --sku 'Standard_LRS' --https-only --min-tls-version 'TLS1_2' --allow-blob-public-access false
    
    export AZURE_ACME_STORAGE_ACCOUNT_CONNECTION_STRING="$(
        az storage account show-connection-string \
            --resource-group "$AZURE_ACME_RESOURCE_GROUP_NAME" --name "$AZURE_ACME_STORAGE_ACCOUNT_NAME" \
            --query connectionString --output tsv
    )"
    
    
    # Create a Function app.
    az functionapp create \
        --resource-group "$AZURE_ACME_RESOURCE_GROUP_NAME" --name "$AZURE_ACME_FUNCTION_APP_NAME" \
        --storage-account "$AZURE_ACME_STORAGE_ACCOUNT_NAME" \
        --consumption-plan-location "$(az group show --name "$AZURE_ACME_RESOURCE_GROUP_NAME" --query location --output tsv)" \
        --functions-version '4' --os-type 'Linux' --runtime 'custom' \
        --assign-identity '[system]' \
        --disable-app-insights
    
    
    # Create a resource group for the Log Analytics workspace.
    az group create --name "$AZURE_LOG_ANALYTICS_WORKSPACE_RESOURCE_GROUP_NAME"
    
    
    # Create a Log Analytics workspace.
    az monitor log-analytics workspace create \
        --resource-group "$AZURE_LOG_ANALYTICS_WORKSPACE_RESOURCE_GROUP_NAME" --workspace-name "$AZURE_LOG_ANALYTICS_WORKSPACE_NAME"
    
    
    # Configure the Function app to log to the Log Analytics workspace.
    az monitor diagnostic-settings create \
        --name 'logs' \
        --resource "$(
            az functionapp show \
                --resource-group "$AZURE_ACME_RESOURCE_GROUP_NAME" --name "$AZURE_ACME_FUNCTION_APP_NAME" \
                --query id --output tsv
        )" \
        --workspace "$(
            az monitor log-analytics workspace show \
                --resource-group "$AZURE_LOG_ANALYTICS_WORKSPACE_RESOURCE_GROUP_NAME" --workspace-name "$AZURE_LOG_ANALYTICS_WORKSPACE_NAME" \
                --query id --output tsv
        )" \
        --logs '[{ "category": "FunctionAppLogs", "enabled": true }]'
    
    
    # Configure the KeyVault to log to the Log Analytics workspace.
    az monitor diagnostic-settings create \
        --name 'logs' \
        --resource "$(
            az keyvault show \
                --name "$AZURE_KEY_VAULT_NAME" \
                --query id --output tsv
        )" \
        --workspace "$(
            az monitor log-analytics workspace show \
                --resource-group "$AZURE_LOG_ANALYTICS_WORKSPACE_RESOURCE_GROUP_NAME" --workspace-name "$AZURE_LOG_ANALYTICS_WORKSPACE_NAME" \
                --query id --output tsv
        )" \
        --logs '[{ "category": "AuditEvent", "enabled": true }]'
    
    
    # Create a custom role for the Function app to access the DNS zone, KeyVault and Log Analytics Workspace.
    az role definition create --role-definition "$(
        jq --null-input \
            --arg 'AZURE_ROLE_NAME' "$AZURE_ACME_ROLE_NAME" \
            --arg 'AZURE_SUBSCRIPTION_ID' "$AZURE_SUBSCRIPTION_ID" \
            '{
                "Name": $AZURE_ROLE_NAME,
                "AssignableScopes": [
                    "/subscriptions/\($AZURE_SUBSCRIPTION_ID)"
                ],
                "Actions": [
                    "Microsoft.Network/dnszones/read",
                    "Microsoft.Network/dnszones/TXT/delete",
                    "Microsoft.Network/dnszones/TXT/write",
                    "Microsoft.OperationalInsights/workspaces/read",
                    "Microsoft.OperationalInsights/workspaces/sharedKeys/action"
                ],
                "DataActions": [
                    "Microsoft.KeyVault/vaults/certificates/create/action",
                    "Microsoft.KeyVault/vaults/certificates/read",
                    "Microsoft.KeyVault/vaults/keys/create/action",
                    "Microsoft.KeyVault/vaults/keys/read",
                    "Microsoft.KeyVault/vaults/keys/sign/action"
                ],
            }'
    )"
    
    
    # Apply the role to the Function app
    function_app_identity="$(
        az functionapp identity show \
            --resource-group "$AZURE_ACME_RESOURCE_GROUP_NAME" --name "$AZURE_ACME_FUNCTION_APP_NAME" \
            --query principalId --output tsv
    )"
    for scope in \
        "$(az network dns zone show --resource-group "$AZURE_COMMON_RESOURCE_GROUP_NAME" --name "$TOP_LEVEL_DOMAIN_NAME" --query id --output tsv)" \
        "$(az network dns zone show --resource-group "$AZURE_COMMON_RESOURCE_GROUP_NAME" --name "$TOP_LEVEL_DOMAIN_NAME" --query id --output tsv)/TXT/_acme-challenge" \
        "$(az keyvault show --name "$AZURE_KEY_VAULT_NAME" --query id --output tsv)/keys/$AZURE_KEY_VAULT_ACME_ACCOUNT_KEY_NAME" \
        "$(az keyvault show --name "$AZURE_KEY_VAULT_NAME" --query id --output tsv)/certificates/$AZURE_KEY_VAULT_CERTIFICATE_NAME" \
        "$(
            az monitor log-analytics workspace show \
                --resource-group "$AZURE_LOG_ANALYTICS_WORKSPACE_RESOURCE_GROUP_NAME" --workspace-name "$AZURE_LOG_ANALYTICS_WORKSPACE_NAME" \
                --query id --output tsv
        )"
    do
        az role assignment create \
            --role "$AZURE_ACME_ROLE_NAME" \
            --assignee "$function_app_identity" \
            --scope "$scope"
    done
  3. If the Azure DNS zone is not your domain's primary nameserver, create NS records on your primary nameserver for the dns-01 challenge.

    echo "Create NS records for _acme-challenge.$TOP_LEVEL_DOMAIN_NAME. to:"; \
    az network dns zone show \
        --resource-group "$AZURE_COMMON_RESOURCE_GROUP_NAME" --name "$TOP_LEVEL_DOMAIN_NAME" \
        --query 'nameServers' --output tsv
  4. Prepare the Log Analytics table schema.

    ./scripts/prepare-loganalytics-table-schema.sh

Test locally

  1. Define some more global variables.

    # Staging endpoint for local testing.
    export ACME_STAGING_DIRECTORY_URL='https://acme-staging-v02.api.letsencrypt.org/directory'
  2. Create a service principal.

    This is only needed for testing locally with the Azure Functions host func. When deployed to Azure, the Function app will use its Managed Service Identity instead.

    export AZURE_ACME_SP_NAME="${TOP_LEVEL_DOMAIN_NAME//./-}-local-testing"
    
    az ad sp create-for-rbac --name "$AZURE_ACME_SP_NAME"
    
    export AZURE_ACME_CLIENT_SECRET='...' # Save `password` - it will not appear again
    export AZURE_ACME_CLIENT_ID="$(az ad sp list --display-name "$AZURE_ACME_SP_NAME" --query '[0].appId' --output tsv)"
  3. Grant the SP access to the Azure resources.

    for scope in \
        "$(az network dns zone show --resource-group "$AZURE_COMMON_RESOURCE_GROUP_NAME" --name "$TOP_LEVEL_DOMAIN_NAME" --query id --output tsv)" \
        "$(az network dns zone show --resource-group "$AZURE_COMMON_RESOURCE_GROUP_NAME" --name "$TOP_LEVEL_DOMAIN_NAME" --query id --output tsv)/TXT/_acme-challenge" \
        "$(az keyvault show --name "$AZURE_KEY_VAULT_NAME" --query id --output tsv)/keys/$AZURE_KEY_VAULT_ACME_ACCOUNT_KEY_NAME" \
        "$(az keyvault show --name "$AZURE_KEY_VAULT_NAME" --query id --output tsv)/certificates/$AZURE_KEY_VAULT_CERTIFICATE_NAME" \
        "$(
            az monitor log-analytics workspace show \
                --resource-group "$AZURE_LOG_ANALYTICS_WORKSPACE_RESOURCE_GROUP_NAME" --workspace-name "$AZURE_LOG_ANALYTICS_WORKSPACE_NAME" \
                --query id --output tsv
        )"
    do
        az role assignment create \
            --role "$AZURE_ACME_ROLE_NAME" \
            --assignee "$AZURE_ACME_CLIENT_ID" \
            --scope "$scope"
    done
  4. Build the Function app and run it in the Functions host.

    ./build.sh debug
    
    curl -D - 'http://localhost:7071/renew-cert'

If you need to force a new certificate to be requested while the previous one in the KeyVault is still valid, delete it:

az keyvault certificate delete --vault-name "$AZURE_KEY_VAULT_NAME" --name "$AZURE_KEY_VAULT_CERTIFICATE_NAME"
sleep 10
az keyvault certificate purge --vault-name "$AZURE_KEY_VAULT_NAME" --name "$AZURE_KEY_VAULT_CERTIFICATE_NAME"

If you need to force the ACME server to return a new certificate even if a previous one is still valid, delete the account key:

az keyvault key delete --vault-name "$AZURE_KEY_VAULT_NAME" --name "$AZURE_KEY_VAULT_ACME_ACCOUNT_KEY_NAME"
sleep 10
az keyvault key purge --vault-name "$AZURE_KEY_VAULT_NAME" --name "$AZURE_KEY_VAULT_ACME_ACCOUNT_KEY_NAME"

Deploy to Azure

./build.sh publish

Monitor (Log Analytics)

Function invocations

FunctionAppLogs_CL
| order by TimeGenerated desc, SequenceNumber_d desc
| where (ObjectType_s == "function_invocation/request" or ObjectType_s == "azure/key_vault/certificate") and isnotempty(FunctionInvocationId_g)
| project TimeGenerated, FunctionInvocationId_g, ObjectType_s, ObjectState_s, ObjectValue_s

Function invocation logs

FunctionAppLogs_CL
| order by TimeGenerated desc, SequenceNumber_d desc
| where FunctionInvocationId_g == "..."
| extend Record =
    case(
        isnotempty(Exception_s), Exception_s,
        isnotempty(Message), Message,
        strcat(
            ObjectType_s,
            iff(isempty(ObjectId_s), "", strcat("/", ObjectId_s)),
            iff(isempty(ObjectOperation_s), strcat(" is ", ObjectState_s), strcat(" does ", ObjectOperation_s, " ", ObjectValue_s))
        )
    )
| project TimeGenerated, Level, Record

Misc

  • The ACME account key is generated with an ECDSA P-384 key by default. This is the most secure algorithm supported by Let's Encrypt; Let's Encrypt does not support P-521 keys (Boulder supports them but the config is turned off) or EdDSA keys.

    You can change the key algorithm in build.sh by changing the value of "azure_key_vault_acme_account_key_type" in the Function app secret settings.

  • The TLS certificate is generated with an RSA 4096-bit key by default. You can change the key algorithm in build.sh by changing the value of "azure_key_vault_certificate_key_type" in the Function app secret settings.

Old F# version

For the old F# version of this Function, see the fsharp branch. That version is no longer maintained.

The Rust version has a few differences compared to the F# version:

  • The F# version had a bunch of dependency hell from Microsoft .Net libraries, like pulling multiple versions of Newtonsoft.Json

  • The F# version used standard structural logging available to .Net Functions via the Microsoft.Extensions.Logging library. These logs were reported to App Insights / Log Analytics via the Functions host. The Rust version logs directly to Log Analytics instead, using its Data Collector API.

  • The F# version worked with the ACME account key and cert private key in memory, and imported/exported them to/from KeyVault for this. The Rust version lets the KeyVault create the keys and uses KeyVault API to sign them.

  • The F# version was limited to running on a Linux Consumption plan, due to .Net on Windows marking certificate private keys as non-exportable and preventing the cert from being used with Azure CDN. The Rust version does its crypto in pure Rust, so it does not have this limitation.

    However the build script in this repository still only builds a Linux binary. If you want to build a Windows binary, you'll need to adapt the script to your needs.

License

AGPL-3.0-only

acme-azure-function

https://github.com/Arnavion/acme-azure-function

Copyright 2021 Arnav Singh

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, version 3 of the
License.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU Affero General Public License for more details.

You should have received a copy of the GNU Affero General Public License
along with this program.  If not, see <https://www.gnu.org/licenses/>.

acme-azure-function's People

Contributors

arnavion avatar

Stargazers

 avatar

Watchers

 avatar  avatar  avatar

acme-azure-function's Issues

Support ARI and prefer it over hard-coded "30 days before expiry" calculation

https://datatracker.ietf.org/doc/draft-ietf-acme-ari/

https://letsencrypt.org/2023/03/23/improving-resliiency-and-reliability-with-ari.html


$ <<< '14:2E:B3:17:B7:58:56:CB:AE:50:09:40:E6:1F:AF:9D:8B:14:C2:C6' tr -d ':' | xxd -r -p | base64 -w 0 | tr '+/' '-_' | tr -d '='; echo

FC6zF7dYVsuuUAlA5h-vnYsUwsY

$ <<< '03:87:46:a9:33:e6:3c:45:bd:1f:98:14:cd:77:59:85:eb:92' tr -d ':' | xxd -r -p | base64 -w 0 | tr '+/' '-_' | tr -d '='; echo

A4dGqTPmPEW9H5gUzXdZheuS

$ curl -sL 'https://acme-v02.api.letsencrypt.org/directory' | gojq -r .renewalInfo

https://acme-v02.api.letsencrypt.org/draft-ietf-acme-ari-02/renewalInfo/

$ curl -LD - 'https://acme-v02.api.letsencrypt.org/draft-ietf-acme-ari-02/renewalInfo/FC6zF7dYVsuuUAlA5h-vnYsUwsY.A4dGqTPmPEW9H5gUzXdZheuS'; echo

HTTP/2 200 
server: nginx
date: Tue, 19 Mar 2024 18:10:18 GMT
content-type: application/json
content-length: 101
cache-control: public, max-age=0, no-cache
link: <https://acme-v02.api.letsencrypt.org/directory>;rel="index"
retry-after: 21600
x-frame-options: DENY
strict-transport-security: max-age=604800

{
  "suggestedWindow": {
    "start": "2024-03-30T00:37:07Z",
    "end": "2024-04-01T00:37:07Z"
  }
}

Does it handle revoked certificates? Then it could be an easier solution to #3

Switch logger to use Azure Monitor DCE API

The "Data Collector API" that logs directly to Log Analytics hasn't been updated since 2016, and there is an article about migrating to Azure Monitor's "Logs Ingestion API". The latter uses standard OAuth with SP credentials instead of the bespoke symmetric key method that the former uses.

https://learn.microsoft.com/en-us/azure/azure-monitor/logs/tutorial-logs-ingestion-portal

https://learn.microsoft.com/en-us/azure/azure-monitor/logs/custom-logs-migrate#migration-procedure

https://learn.microsoft.com/en-us/cli/azure/monitor/data-collection/rule

https://learn.microsoft.com/en-us/rest/api/monitor/data-collection-rules/create

[renew-cert] Handle multiple authorizations

We expect only one authorization, which works for Let's Encrypt, but in general the server may require multiple authorizations. We should handle that too.

It might require a bunch of restructuring from needing to have multiple challenges in flight at the same time between BeginOrder and EndOrder.

DNS-01 challenge for wildcard certificates?

First, great work!! Very useful for me as I am using F# for all the microservices etc.

I see that you have implemented the HTTP-01 challenge only, but as I want to issue wildcard certificate I would have to use DNS-01 ... Have you done anything in that direction? I guess I have to just replace the part where you put a blob on the storage account with some API calls to Azure DNS (in my case DNS is Azure hosted as well) ..

[acme] Use Retry-After when set

For "processing" orders and "pending" authorizations, the server can return a Retry-After header which the Function ought to honor.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.