Gestion automatisée de certificats TLS avec Let’s Encrypt via Terraform et Ansible sur AWS

Qui ne s’est jamais fait surprendre par le renouvellement de certificats Transport Layer Security (TLS) ? Dans certains cas, rien n’a été prévu pour le renouvellement des certificats et l’on constate sur les serveurs des erreurs TLS (déjà vécu en production), dans d’autres cas plus éclairés, on a anticipé un rappel quelques jours avant la péremption, mais on se retrouve malgré tout à devoir mettre à jour les certificats au dernier moment dans la précipitation sur nos serveurs (cas où les certificats peuvent être fournis par un tiers par exemple), et j’en passe.

Que diriez-vous de ne plus avoir à vous soucier du renouvellement de vos certificats TLS ? Que diriez-vous d’avoir une solution complètement automatisée pour la gestion du renouvellement de vos certificats ? Ceci est désormais possible avec le protocole Automatic Certificate Management Environment (ACME) créé par la société californienne d’utilité publique Internet Security Research Group (ISRG) en 2016 et utilisé via leur Certificate Authority (CA) Let’s Encrypt, elle aussi lancée en 2016. Le but de cet article n’est pas d’expliquer en détails comment fonctionne TLS ou encore le protocole ACME via le service Let’s Encrypt.

Ceci étant dit, afin de mieux comprendre la suite de l’article, un tour d’horizon du service Let’s Encrypt s’impose.

Comment fonctionne Let’s Encrypt ?

Let’s Encrypt est une CA permettant la mise en place d’un serveur HTTPS via l’obtention automatique d’un certificat de confiance reconnu nativement par la plupart des browsers (voir la compatibilité).

La première fois qu’un client (à noter que le client sera ici un serveur) va interagir avec Let’s Encrypt, il va générer une paire de clés pour prouver qu’il contrôle un domaine. Ce dernier va ensuite émettre un ou plusieurs défis au client pour prouver que ce dernier contrôle bien le domaine (la validation du domaine est très bien expliquée sur le site de Let’s Encrypt). Un défi est une action que le client de Let’s Encrypt va devoir réaliser pour prouver sa bonne foi, il peut par exemple se présenter sous la forme d’un enregistrement Domain Name System (DNS) ou bien encore d’un enregistrement HTTP sur une URI connue. Il est important de noter que parmi les défis disponibles, le seul permettant de générer des certificats wildcard au moment de l’écriture de cet article est le défi DNS. Une fois la validation du domaine faite, le client ACME pourra créer/renouveler/révoquer des certificats en construisant une Certificate Signing Request (CSR).

Les clients ACME permettant d'interagir avec Let’s Encrypt sont nombreux mais comme vous l’aurez certainement deviné, nous allons par la suite utiliser un client ACME Terraform.

Utilisation du provider Terraform ACME

Comme le cas d’usage de Terraform veut que ce dernier soit exécuté sur un autre serveur que celui sur lequel sera placé le certificat TLS, le provider Terraform ACME supporte seulement les défis DNS (et non HTTP).

Pour la suite de l’article, on va supposer que l’on est détenteur du domaine suivant : acme-on-aws.com.

Import du provider

La première chose sur laquelle il faut être prudent avant de commencer à faire des tests est d’utiliser le serveur Let’s Encrypt de staging pour éviter d’atteindre les limites fixées par le serveur de production. Personnellement, je déclare 2 providers dans mon code en donnant un alias au provider de production pour utiliser le serveur de staging par défaut :

provider "acme" {
  version    = "= 1.5.0"
  server_url = "https://acme-staging-v02.api.letsencrypt.org/directory"
}

provider "acme" {
  alias      = "production"
  version    = "= 1.5.0"
  server_url = "https://acme-v02.api.letsencrypt.org/directory"
}

Il est important de noter que le serveur de staging utilise un certificat intermédiaire “Fake LE Intermediate X1” qui est émis par un certificat racine qui n’est pas reconnu par les browser trust stores.

Je vous conseille vivement de fixer la version des providers (cette remarque est valable pour n’importe quel provider Terraform) pour éviter des régressions. C’est d’autant plus vrai si vous avez une chaîne de CI/CD complètement automatisée dans laquelle les commandes terraform apply sont passés sans validation humaine. Des outils de tests d’infrastructure comme Terratest pourront également vous aider à éviter des régressions.

Gestion de comptes sur un serveur ACME

La première chose à faire avant de pouvoir demander des certificats TLS à Let’s Encrypt est d’enregistrer un compte sur leur serveur. Il vous faudra pour cette étape une clé privée pour identifier le compte. Nous allons en générer une via le provider Terraform TLS :

provider "tls" {
  version = "= 2.1.1"
}

variable "acme_email" {
  type    = string
  default = "somebody@acme-on-aws.com"
}

resource "tls_private_key" "staging_account_private_key" {
  algorithm = "RSA"
  rsa_bits  = "4096"
}

resource "acme_registration" "staging_account_registration" {
  account_key_pem = tls_private_key.staging_account_private_key.private_key_pem
  email_address   = var.acme_email
}

resource "tls_private_key" "production_account_private_key" {
  algorithm = "RSA"
  rsa_bits  = "4096"
  
  lifecycle {
    prevent_destroy = true
  }
}

resource "acme_registration" "production_account_registration" {
  provider        = acme.production
  account_key_pem = tls_private_key.production_account_private_key.private_key_pem
  email_address   = var.acme_email

  lifecycle {
    prevent_destroy = true
  }
}

Faites bien attention à ne pas perdre la clé privée de votre compte ACME car sans elle, il vous sera impossible de renouveler/révoquer des certificats par la suite. Ici, je me protège d’une potentielle destruction de la clé et du compte de production grâce au lifecycle meta-argument prevent_destroy de Terraform. Pour créer le compte Let’s Encrypt de production, j’utilise le provider alternatif de production via le meta-argument provider grâce à l’alias mis en place auparavant.

Un autre point important ici, vous l’aurez compris, est que la clé privée du compte sera stockée en clair dans le state Terraform. Il est donc extrêmement important de le chiffrer au repos et de limiter ses accès au maximum. J’ai pour habitude de limiter l’accès aux traitements CI/CD, par exemple sur AWS, aux utilisateurs/rôles Identity Access Management (IAM) qui utilisent Terraform.

Gestion de certificats TLS Let’s Encrypt sur AWS

Pour la suite de l’article, les exemples donnés se baseront sur des certificats Let’s Encrypt générés sur AWS.

Génération d’un premier certificat

Afin de pouvoir valider et générer des certificats ACME, il faut au préalable avoir créé une zone Route 53 publique qui pointe sur notre domaine, c’est-à-dire acme-on-aws.com dans notre cas. Il est assez simple d’acheter un nom de domaine via Route 53 Registrar sur AWS, cela ne coûte qu’une dizaine de dollars américains.

Dans l’exemple qui suit, je vais générer un certificat pour une devfactory sur le domaine primaire gitlab.acme-on-aws.com et alternatif sonarqube.acme-on-aws.com avec le provider ACME de staging :

variable "region" {
  description = "AWS region"
  type        = string
  default     = "eu-west-1"
}

variable "domain_name" {
  type    = string
  default = "acme-on-aws.com"
}

provider "aws" {
  version = "= 2.51.0"
  region  = var.region
}

resource "aws_route53_zone" "main_public_route53_zone" {
  name    = "${var.domain_name}."
  comment = "Main internet public domain"

  lifecycle {
    prevent_destroy = true
  }
}

resource "acme_certificate" "devfactory_certificate" {
  account_key_pem = acme_registration.staging_account_registration.account_key_pem
  
  common_name = "gitlab.${var.domain_name}"
  subject_alternative_names = [
    "sonarqube.${var.domain_name}"
  ]

  recursive_nameservers = [
    "${aws_route53_zone.main_public_route53_zone.name_servers.0}:53",
    "${aws_route53_zone.main_public_route53_zone.name_servers.1}:53",
    "${aws_route53_zone.main_public_route53_zone.name_servers.2}:53",
    "${aws_route53_zone.main_public_route53_zone.name_servers.3}:53",
  ]

  dns_challenge {
    provider = "route53"
    
    config = {
      AWS_HOSTED_ZONE_ID = aws_route53_zone.main_public_route53_zone.zone_id
    }
  }
}

Il est à noter ici que l’on a utilisé un common name et un subject alternative name pour spécifier les domaines du certificat TLS, mais il aurait également été possible de passer par une Certificate Signing Request (CSR) ; vous pouvez vous référer à la documentation de la ressource acme_certificate pour plus de détails.

Deux choses sont importantes à la génération d’un certificat ACME dans le cas où vous auriez plusieurs zones Route 53 (publiques et privées) sur votre compte AWS (ce qui est très fréquent si vous avez plusieurs environnements par exemple) :

  • Il faut penser à surcharger l’ID de la zone Route 53 AWS_HOSTED_ZONE_ID pour le DNS challenge afin d’indiquer au client ACME de créer l’entrée DNS texte (TXT) “_acme-challenge.<YOUR_DOMAIN>” dans la bonne zone réseau.
  • Il faut également surcharger les recursive_nameservers pour utiliser ceux de la zone Route 53 publique dans laquelle l’entrée DNS TXT a été créée.

Dans mon contexte, j’avais plusieurs environnements sur AWS (un environnement correspondant à un VPC) et chaque environnement avait deux zones Route 53, une publique et une privée. Sans la surcharge de ces deux paramètres, il m’est arrivé d’avoir des entrées DNS TXT créées dans une zone Route 53 privée, il était donc impossible de résoudre le défi DNS.

Délégation DNS

Dans le cas où vous souhaiteriez faire de la délégation DNS Route 53 (si vous avez plusieurs environnements applicatifs par exemple), il vous faudra ajouter une entrée DNS de type Name Server (NS) dans votre zone Route 53 publique principale qui pointe vers les NS de la zone Route 53 publique de votre sous-domaine afin de router le trafic réseau correctement. Dans l’exemple ci-après, on va mettre en place une zone Route 53 pour un environnement applicatif que j’ai appelé dev :

variable "applicative_env" {
  type    = string
  default = "dev"
}

locals {
  dev_fqdn = "${var.applicative_env}.${var.domain_name}."
}

resource "aws_route53_zone" "dev_public_env_zone" {
  name    = local.dev_fqdn
  comment = "${var.applicative_env} public domain"

  tags = {
    Name = "${var.applicative_env}:public-route53-zone"
    env  = var.applicative_env
  }
}

// Delegating authority for 'dev.acme-on-aws.com' subdomain to the main
// public Route53 hosted zone
resource "aws_route53_record" "dev_subdomain_NS_record" {
  name    = local.dev_fqdn
  ttl     = 60
  type    = "NS"
  zone_id = aws_route53_zone.main_public_route53_zone.zone_id
  
  records = [
    aws_route53_zone.dev_public_env_zone.name_servers.0,
    aws_route53_zone.dev_public_env_zone.name_servers.1,
    aws_route53_zone.dev_public_env_zone.name_servers.2,
    aws_route53_zone.dev_public_env_zone.name_servers.3,
  ]
}

Comme la délégation DNS est maintenant en place, nous allons pouvoir générer un certificat wildcard via le provider ACME de production pour l’environnement applicatif dev. Ce certificat sera ensuite importé dans AWS Certificate Manager (ACM) et pourra plus tard être utilisé dans un Application Load Balancer (ALB) qui serait exposé sur internet afin d’exposer une application par exemple. Pour les besoins de l’article, j’ai mis la partie du code Terraform qui nous intéresse à plat, mais en réalité, il serait mieux de passer par un module Terraform qui pourrait être ré-utilisé pour générer plusieurs certificats ACME/ACM (voir l’ensemble du code sur mon repository GitHub).

resource "acme_certificate" "dev_wildcard_certificate" {
  provider = acme.production
  
  account_key_pem = acme_registration.production_account_registration.account_key_pem

  common_name = "*.${local.dev_fqdn}"
  
  recursive_nameservers = [
    "${aws_route53_zone.dev_public_env_zone.name_servers.0}:53",
    "${aws_route53_zone.dev_public_env_zone.name_servers.1}:53",
    "${aws_route53_zone.dev_public_env_zone.name_servers.2}:53",
    "${aws_route53_zone.dev_public_env_zone.name_servers.3}:53",
  ]

  dns_challenge {
    provider = "route53"

    config = {
      AWS_HOSTED_ZONE_ID = aws_route53_zone.dev_public_env_zone.zone_id
    }
  }
}

resource "aws_acm_certificate" "dev_acm_certificate" {
  private_key       = acme_certificate.dev_wildcard_certificate.private_key_pem
  certificate_body  = acme_certificate.dev_wildcard_certificate.certificate_pem
  certificate_chain = "${acme_certificate.dev_wildcard_certificate.certificate_pem}${acme_certificate.dev_wildcard_certificate.issuer_pem}"

  tags = {
    Name = "${var.applicative_env}-wildcard-certificate"
    env  = var.applicative_env
  }

  lifecycle {
    ignore_changes = [
      options,
    ]
  }
}

Vous noterez qu’on utilise cette fois-ci la zone Route 53 du sous-domaine de l’environnement applicatif de développement aws_route53_zone.dev_public_env_zone pour le défi DNS et non la zone de notre domaine de base. Il sera ensuite possible de référencer l ‘ARN du certificat ACM dev_acm_certificate dans un ALB.

Renouvellement d’un certificat

La ressource acme_certificate gère automatiquement le renouvellement d’un certificat ACME tant qu’un plan ou un apply Terraform est effectué dans le nombre de jours spécifié via la propriété min_days_remaining. Par défaut, cette propriété est fixée à 30 jours mais elle est customisable. Pendant la phase de refresh de Terraform, si ce dernier détecte que le certificate ACME n’a plus que 30 jours de vie ou moins avant son expiration ou alors qu’il est déjà expiré, Terraform marquera ce certificat comme étant à renouveler au prochain apply.

Comme vous l’aurez deviné, il devient donc très facile d’automatiser le renouvellement de certificats ACME. Il vous suffira simplement d’avoir un job automatisé qui lance un apply Terraform régulièrement sur vos states qui contiennent des certificats. Au moment du renouvellement, si vous avez importé votre certificat ACME dans un certificat ACM, ce dernier sera automatiquement mis à jour sur AWS et dans vos ALB sans downtime.

En ce qui concerne la mise à jour du certificat ACM, j’ai rencontré un souci probablement lié à AWS qui faisait que le renouvellement d’un certificat ACME entraînait une recréation du certificat ACM lors de l’apply Terraform alors que ce dernier était en cours d’utilisation sur plusieurs ALB. Ceci n’était évidemment pas possible dans mon cas (l’apply finissait par échouer) et n’était pas nécessaire. Cette recréation de ressource était liée au certificate_transparency_logging_preference qui est une option qu’il est possible de spécifier lors de la création d’un certificat ACM. Pour contourner le problème, j’ai utilisé le lifecycle meta-argument ignore_changes de Terraform pour ignorer des modifications externes à Terraform sur ce champ (il y a des détails supplémentaires en commentaire dans le code Terraform sur mon repository GitHub).

Stockage et lecture des secrets TLS

Stockage des secrets via Terraform

Dans le cas où vous auriez besoin de déposer les certificats générés avec le provider Terraform ACME sur une machine virtuelle (ce qui est régulièrement le cas dans une infrastructure), il va falloir stocker les secrets TLS (certificats et clés privées) dans un gestionnaire de secrets.

Dans la suite de cet article, nous utiliserons AWS Secrets Manager comme gestionnaire de secrets mais il aurait tout à fait été possible d’utiliser un autre gestionnaire comme HashiCorp Vault via le provider Terraform Vault. L’avantage de Secrets Manager est que le temps de prise en main et de montée en compétences est bien plus rapide qu’avec HashiCorp Vault. Si vous êtes donc pris par le temps ou si vous êtes le seul consultant DevOps sur votre mission, je vous recommande vivement de privilégier cette solution.

Dans l’état actuel (avec le code montré précédemment), nos secrets TLS générés via le provider ACME sont simplement stockés dans le state Terraform et ne sont donc pas facilement accessibles. Nous allons utiliser la ressource Terraform aws_secretsmanager_secret qui permet de créer un secret et aws_secretsmanager_secret_version qui permet de gérer les valeurs de ce secret. J’ai pris pour habitude de rendre paramétrable le recovery des secrets dans Secrets Manager en fonction de l’environnement sur lequel je me trouve grâce au paramètre recovery_window_in_days de la ressource aws_secretsmanager_secret. Cela permet de détruire plus facilement et de recréer les mêmes secrets via Terraform dans le cas d’environnements éphémères. Ce paramètre est ici fixé à 0 (pour être supprimé immédiatement d’AWS sans possibilité de récupération) mais pensez bien à changer sa valeur pour votre environnement de production afin de pouvoir récupérer un secret supprimé par mégarde par exemple.

variable "secret_recovery_window_in_days" {
  type = number
  // For tests purposes only
  default = 0
}

locals {
  dev_tls_keys_secret = {
    private_key = acme_certificate.dev_wildcard_certificate.private_key_pem
  }
  dev_tls_certificates_secret = {
    cert        = acme_certificate.dev_wildcard_certificate.certificate_pem
    issuer_cert = acme_certificate.dev_wildcard_certificate.issuer_pem
    chain_cert  = "${acme_certificate.dev_wildcard_certificate.certificate_pem}${acme_certificate.dev_wildcard_certificate.issuer_pem}"
  }
}

resource "aws_secretsmanager_secret" "dev_tls_private_keys_secret" {
  name                    = "${var.applicative_env}/production/tls/private_keys"
  description             = "${var.applicative_env} environment Production TLS private keys"
  recovery_window_in_days = var.secret_recovery_window_in_days

  tags = {
    env  = var.applicative_env
    role = "tls"
    type = "production"
  }
}

resource "aws_secretsmanager_secret_version" "dev_tls_private_keys_secret_version" {
  secret_id     = aws_secretsmanager_secret.dev_tls_private_keys_secret[0].id
  secret_string = jsonencode(local.dev_tls_keys_secret)
}

resource "aws_secretsmanager_secret" "dev_tls_certificates_secret" {
  name                    = "${var.applicative_env}/production/tls/certificates"
  description             = "${var.applicative_env} environment Production TLS certificates"
  recovery_window_in_days = var.secret_recovery_window_in_days

  tags = {
    env  = var.applicative_env
    role = "tls"
    type = "production"
  }
}

resource "aws_secretsmanager_secret_version" "dev_tls_certificates_secret_version" {
  secret_id     = aws_secretsmanager_secret.dev_tls_certificates_secret[0].id
  secret_string = jsonencode(local.dev_tls_certificates_secret)
}

J’utilise ici la fonction jsonencode de Terraform afin de stocker des données sous la forme clé/valeur dans un seul et même secret, cela permettra entre autre de simplifier le travail de lecture de secrets via Ansible par la suite.

Afin de chiffrer les secrets au repos, je vous recommande d’utiliser le service de chiffrement d’AWS Key Management Service (KMS) dans la ressource aws_secretsmanager_secret. Cela n’a pas été fait dans l’exemple ci-dessus pour simplifier le code.

Lecture des secrets via Ansible

Le travail est presque terminé et la lecture des secrets via Ansible dans Secrets Manager est extrêmement simple, il faut simplement être au minimum en version 2.8 d’Ansible. On va utiliser le lookup plugin aws_secret pour aller lire des secrets sur AWS dans Secrets Manager (sachez qu’il existe le même genre de plugin Ansible pour HashiCorp Vault qui s'appelle hashi_vault).

Voilà ce que donnerait un playbook très simple pour déposer le certificat wildcard de notre environnement dev et sa clé privée créés précédemment via Terraform dans /tmp sur localhost :

- hosts: localhost
  connection: local
  gather_facts: false
  tasks:
    - name: "Copy TLS files on local filesystem"
      no_log: true
      copy:
        content: "{{ tls_file.content }}"
        dest: "{{ tls_file.dest }}"
        mode: "{{ tls_file.mode }}"
      loop:
        - { content: "{{ (lookup('aws_secret', 'dev/production/tls/certificates') | from_json).get('chain_cert') }}", dest: "/tmp/dev.crt", mode: "0600" }
        - { content: "{{ (lookup('aws_secret', 'dev/production/tls/private_keys') | from_json).get('private_key') }}", dest: "/tmp/dev.key", mode: "0600" }
      loop_control:
        loop_var: tls_file

Ce n’est pas précisé ici, mais il faut évidemment avoir des credentials AWS dans le contexte Ansible avec des droits IAM adéquats, c’est-à-dire des droits permettant de lire les secrets dev/production/tls/certificates ainsi que dev/production/tls/private_keys (ne pas oublier d’autoriser l’utilisateur AWS d’Ansible à accéder à la clé KMS utilisée pour chiffrer les secrets dans Secrets Manager).

Vous noterez l’utilisation de la directive no_log: true afin de dissimuler dans les logs Ansible les données secrètes. Soyez cependant prudent avec cette directive si vous êtes en mode DEBUG, cela n’affectera pas la sortie Ansible (pas de DEBUG sur vos playbooks de production !).

Il serait très facile d’ajouter sur la task Copy TLS files on local filesystem un notify sur un handler qui viendrait redémarrer l’application portant le certificat TLS (que ce soit un serveur NGinx, un serveur Apache HTTPD ou autre).

Une chaîne complètement automatisée

Alors, en a-t-on réellement fini avec les mises à jour de dernière minute des certificats sur nos serveurs ? Avec les outils présentés précédemment et si vous avez la possibilité d’utiliser Let’s Encrypt (ou à minima ACME) sur votre projet, il vous suffira d’avoir des jobs automatisés dans votre chaîne de CI/CD (à l’image du workflow que j’ai mis en place via GitHub Actions). Ces derniers lanceront régulièrement vos apply Terraform puis vos playbooks Ansible pour que vos certificats TLS soient automatiquement mis à jour dans vos applications.