Skip to content

Traefik wildcard certificate using azure dns

wordpress meta
title: 'Traefik Wildcard Certificate using Azure DNS'
date: '2020-05-20T11:47:52-05:00'
status: publish
permalink: /traefik-wildcard-certificate-using-azure-dns
author: admin
excerpt: ''
type: post
id: 1620
category:
    - Azure
    - Docker
    - LetsEncrypt
    - Traefik
tag: []
post_format: []

dns challenge letsencrypt Azure DNS

Using Traefik as edge router(reverse proxy) to http sites and enabling a Lets Encrypt ACME v2 wildcard certificate on the docker Traefik container. Verify ourselves using DNS, specifically the dns-01 method, because DNS verification doesn’t interrupt your web server and it works even if your server is unreachable from the outside world. Our DNS provider is Azure DNS.

Azure Configuration

Pre-req

  • azure cli setup
  • Wildcard DNS entry *.my.domain

Get subscription id

```bash $ az account list | jq '.[] | .id' "masked..."


Create role
-----------

```bash
$ az role definition create --role-definition role.json 
  {
    "assignableScopes": [
      "/subscriptions/masked..."
    ],
    "description": "Can manage DNS TXT records only.",
    "id": "/subscriptions/masked.../providers/Microsoft.Authorization/roleDefinitions/masked...",
    "name": "masked...",
    "permissions": [
      {
        "actions": [
          "Microsoft.Network/dnsZones/TXT/*",
          "Microsoft.Network/dnsZones/read",
          "Microsoft.Authorization/*/read",
          "Microsoft.Insights/alertRules/*",
          "Microsoft.ResourceHealth/availabilityStatuses/read",
          "Microsoft.Resources/deployments/read",
          "Microsoft.Resources/subscriptions/resourceGroups/read"
        ],
        "dataActions": [],
        "notActions": [],
        "notDataActions": []
      }
    ],
    "roleName": "DNS TXT Contributor",
    "roleType": "CustomRole",
    "type": "Microsoft.Authorization/roleDefinitions"
  }

NOTE: If you screwed up and need to delete do like like this:
az role definition delete --name "DNS TXT Contributor"

Create json file with correct subscription and create role definition

```bash $ cat role.json { "Name":"DNS TXT Contributor", "Id":"", "IsCustom":true, "Description":"Can manage DNS TXT records only.", "Actions":[ "Microsoft.Network/dnsZones/TXT/", "Microsoft.Network/dnsZones/read", "Microsoft.Authorization//read", "Microsoft.Insights/alertRules/*", "Microsoft.ResourceHealth/availabilityStatuses/read", "Microsoft.Resources/deployments/read", "Microsoft.Resources/subscriptions/resourceGroups/read" ], "NotActions":[

],
"AssignableScopes":[
  "/subscriptions/masked..."
]

}

$ az role definition create --role-definition role.json { "assignableScopes": [ "/subscriptions/masked..." ], "description": "Can manage DNS TXT records only.", "id": "/subscriptions/masked.../providers/Microsoft.Authorization/roleDefinitions/masked...", "name": "masked...", "permissions": [ { "actions": [ "Microsoft.Network/dnsZones/TXT/", "Microsoft.Network/dnsZones/read", "Microsoft.Authorization//read", "Microsoft.Insights/alertRules/*", "Microsoft.ResourceHealth/availabilityStatuses/read", "Microsoft.Resources/deployments/read", "Microsoft.Resources/subscriptions/resourceGroups/read" ], "dataActions": [], "notActions": [], "notDataActions": [] } ], "roleName": "DNS TXT Contributor", "roleType": "CustomRole", "type": "Microsoft.Authorization/roleDefinitions" }


Checking DNS and resource group
-------------------------------

```bash
$ az network dns zone list
  [
    {
      "etag": "masked...",
      "id": "/subscriptions/masked.../resourceGroups/sites/providers/Microsoft.Network/dnszones/iqonda.net",
      "location": "global",
      "maxNumberOfRecordSets": 10000,
      "name": "masked...",
      "nameServers": [
        "ns1-09.azure-dns.com.",
        "ns2-09.azure-dns.net.",
        "ns3-09.azure-dns.org.",
        "ns4-09.azure-dns.info."
      ],
      "numberOfRecordSets": 14,
      "registrationVirtualNetworks": null,
      "resolutionVirtualNetworks": null,
      "resourceGroup": "masked...",
      "tags": {},
      "type": "Microsoft.Network/dnszones",
      "zoneType": "Public"
    }
  ]

$ az network dns zone list --output table
  ZoneName    ResourceGroup    RecordSets    MaxRecordSets
  ----------  ---------------  ------------  ---------------
  masked...  masked...            14            10000

$ az group list --output table
  Name                                Location        Status
  ----------------------------------  --------------  ---------
  cloud-shell-storage-southcentralus  southcentralus  Succeeded
  masked...                    eastus          Succeeded
  masked...                    eastus          Succeeded
  masked...                    eastus          Succeeded

role assign

```bash $ az ad sp create-for-rbac --name "Acme2DnsValidator" --role "DNS TXT Contributor" --scopes "/subscriptions/masked.../resourceGroups/sites/providers/Microsoft.Network/dnszones/masked..." Changing "Acme2DnsValidator" to a valid URI of "http://Acme2DnsValidator", which is the required format used for service principal names Found an existing application instance of "masked...". We will patch it Creating a role assignment under the scope of "/subscriptions/masked.../resourceGroups/sites/providers/Microsoft.Network/dnszones/masked..." { "appId": "masked...", "displayName": "Acme2DnsValidator", "name": "http://Acme2DnsValidator", "password": "masked...", "tenant": "masked..." }

$ az ad sp create-for-rbac --name "Acme2DnsValidator" --role "DNS TXT Contributor" --scopes "/subscriptions/masked.../resourceGroups/masked..." Changing "Acme2DnsValidator" to a valid URI of "http://Acme2DnsValidator", which is the required format used for service principal names Found an existing application instance of "masked...". We will patch it Creating a role assignment under the scope of "/subscriptions/masked.../resourceGroups/masked..." { "appId": "masked...", "displayName": "Acme2DnsValidator", "name": "http://Acme2DnsValidator", "password": "masked...", "tenant": "masked..." }

$ az role assignment list --all | jq -r '.[] | [.principalName,.roleDefinitionName,.scope]' [ "http://Acme2DnsValidator", "DNS TXT Contributor", "/subscriptions/masked.../resourceGroups/masked..." ] [ "masked...", "Owner", "/subscriptions/masked.../resourcegroups/masked.../providers/Microsoft.Storage/storageAccounts/masked..." ] [ "http://Acme2DnsValidator", "DNS TXT Contributor", "/subscriptions/masked.../resourceGroups/masked.../providers/Microsoft.Network/dnszones/masked..." ]

$ az ad sp list | jq -r '.[] | [.displayName,.appId]' The result is not complete. You can still use '--all' to get all of them with long latency expected, or provide a filter through command arguments ...

[ "AzureDnsFrontendApp", "masked..." ]

[ "Azure DNS", "masked..." ]


### Traefik Configuration

reference
---------

- [Traefik use Lego the Let’s Encrypt client and ACME library written in Go](https://go-acme.github.io/lego/)
- [Lego Azure section](https://go-acme.github.io/lego/dns/azure/)
- [ACME](https://ietf-wg-acme.github.io/acme/draft-ietf-acme-acme.html#rfc.section.8.4)
- [Free Wildcard Certificates using Azure DNS, Let’s Encrypt and acme.sh](https://noobient.com/2018/04/10/free-wildcard-certificates-using-azure-dns-lets/)
- [DoTheEvo / Traefik-v2-examples](https://github.com/DoTheEvo/Traefik-v2-examples#5-lets-encrypt-certificate-DNS-challenge-on-cloudflare)

Azure Credentials in environment file
-------------------------------------

```bash
$ cat .env
    AZURE_CLIENT_ID=masked...
    AZURE_CLIENT_SECRET=masked...
    AZURE_SUBSCRIPTION_ID=masked...
    AZURE_TENANT_ID=masked...
    AZURE_RESOURCE_GROUP=masked...
    #AZURE_METADATA_ENDPOINT=

Traefik Files

```bash $ cat traefik.yml ## STATIC CONFIGURATION log: level: INFO

api:
  insecure: true
  dashboard: true

entryPoints:
  web:
    address: ":80"
  websecure:
    address: ":443"

providers:
  docker:
    endpoint: "unix:///var/run/docker.sock"
    exposedByDefault: false

certificatesResolvers:
  lets-encr:
    acme:
      #caServer: https://acme-staging-v02.api.letsencrypt.org/directory
      storage: acme.json
      email: admin@my.doman
      dnsChallenge:
        provider: azure

    $ cat docker-compose.yml 
    version: "3.3"

    services:

        traefik:
          image: "traefik:v2.2"
          container_name: "traefik"
          restart: always
          env_file:
            - .env
          command:
            #- "--log.level=DEBUG"
            - "--api.insecure=true"
            - "--providers.docker=true"
            - "--providers.docker.exposedbydefault=false"
          labels:
             ## DNS CHALLENGE
             - "traefik.http.routers.traefik.tls.certresolver=lets-encr"
             - "traefik.http.routers.traefik.tls.domains[0].main=*.iqonda.net"
             - "traefik.http.routers.traefik.tls.domains[0].sans=iqonda.net"
             ## HTTP REDIRECT
             #- "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https"
             #- "traefik.http.routers.redirect-https.rule=hostregexp(`{host:.+}`)"
             #- "traefik.http.routers.redirect-https.entrypoints=web"
             #- "traefik.http.routers.redirect-https.middlewares=redirect-to-https"
          ports:
            - "80:80"
            - "8080:8080" #Web UI
            - "443:443"
          volumes:
            - "/var/run/docker.sock:/var/run/docker.sock:ro"
            - "./traefik.yml:/traefik.yml:ro"
            - "./acme.json:/acme.json"
          networks:
            - external_network

        whoami:
          image: "containous/whoami"
          container_name: "whoami"
          restart: always
          labels:
            - "traefik.enable=true"
            - "traefik.http.routers.whoami.entrypoints=web"
            - "traefik.http.routers.whoami.rule=Host(`whoami.iqonda.net`)"
            #- "traefik.http.routers.whoami.tls.certresolver=lets-encr"
            #- "traefik.http.routers.whoami.tls=true"
          networks:
            - external_network

        db:
          image: mariadb
          container_name: "db"
          volumes:
            - db_data:/var/lib/mysql
          restart: always
          environment:
            MYSQL_ROOT_PASSWORD: somewordpress
            MYSQL_DATABASE: wordpress
            MYSQL_USER: wordpress
            MYSQL_PASSWORD: wordpress
          networks:
            - internal_network

        wpsites:
          depends_on:
            - db
          ports:
            - 8002:80
          image: wordpress:latest
          container_name: "wpsites"
          volumes:
            - /d01/html/wpsites.my.domain:/var/www/html
          restart: always
          environment:
            WORDPRESS_DB_HOST: db:3306
            WORDPRESS_DB_USER: wpsites
            WORDPRESS_DB_NAME: wpsites
          labels:
             - "traefik.enable=true"
             - "traefik.http.routers.wpsites.rule=Host(`wpsites.my.domain`)"
             - "traefik.http.routers.wpsites.entrypoints=websecure"
             - "traefik.http.routers.wpsites.tls.certresolver=lets-encr"
             - "traefik.http.routers.wpsites.service=wpsites-svc"
             - "traefik.http.services.wpsites-svc.loadbalancer.server.port=80"
          networks:
            - external_network
            - internal_network

    volumes:
          db_data: {}

    networks:
      external_network:
      internal_network:
        internal: true

WARNING: If you are not using the staging endpoint for LetsEncrypt strongly reconside doing that while working on this. You can get blocked for a week.

Start Containers
----------------

```bash
$ docker-compose up -d --build
whoami is up-to-date
Recreating traefik ... 
db is up-to-date
...
Recreating traefik ... done

Showing some log issues you may see

```bash $ docker logs traefik -f ... time="2020-05-17T21:17:40Z" level=info msg="Testing certificate renew..." providerName=lets-encr.acme ... time="2020-05-17T21:17:51Z" level=error msg="Unable to obtain ACME certificate for domains ..."AADSTS7000215: Invalid client secret is provided.

$ docker logs traefik -f ... \"keyType\":\"RSA4096\",\"dnsChallenge\":{\"provider\":\"azure\"},\"ResolverName\":\"lets-encr\",\"store\":{},\"ChallengeStore\":{}}" acme: error presenting token: azure: dns.ZonesClient#Get: Invalid input: autorest/validation: validation failed: parameter=resourceGroupName constraint=Pattern value=\"\\"sites\\"\" details: value

$ docker logs traefik -f ... time="2020-05-17T22:23:38Z" level=info msg="Starting provider acme.Provider {\"email\":\"admin@iqonda.com\",\"caServer\":\"https://acme-staging-v02.api.letsencrypt.org/ directory\",\"storage\":\"acme.json\",\"keyType\":\"RSA4096\",\"dnsChallenge\":{\"provider\":\"azure\"},\"ResolverName\":\"lets-encr\",\"store\":{},\"ChallengeStore\":{}}" time="2020-05-17T22:23:38Z" level=info msg="Testing certificate renew..." providerName=lets-encr.acme time="2020-05-17T22:23:38Z" level=info msg="Starting provider traefik.Provider {}" time="2020-05-17T22:23:38Z" level=info msg="Starting provider *docker.Provider {\"watch\":true,\"endpoint\":\"unix:///var/run/docker.sock\",\"defaultRule\":\"Host({{ normalize .Name }})\",\"swarmModeRefreshSeconds\":15000000000}" time="2020-05-17T22:23:48Z" level=info msg=Register... providerName=lets-encr.acme ````

In a browser looking at cert this means working but still stage url: CN=Fake LE Intermediate X1

NOTE: In Azure DNS activity log i can see TXT record was created and deleted. Record will be something like this: _acme-challenge.my.domain

Browser still not showing lock. Test with https://www.whynopadlock.com and in my case was just a hardcoded image on the page making it insecure.