Renouvellement automatique d'un certificat Let's encrypt

Développement d'un script python qui automatise le renouvellement d'un certificat Let's encrypt

Contexte

J'ai beau mettre un rappel sur mon téléphone, tous les 3 mois c'est la même chose, je néglige de mettre à jour mon certificat.

Du coup, résolution 2021, je fais un script pour automatiser tout ça.

Mon certificat est fourni par let's encrypt et pour la mise à jour j'utilise Certbot.

Comme j'ai plusieurs sous-domaine je veux obtenir un certificat Wildcard (*.pasgrave.net) par la méthode du DNS challenge.

Comment fonctionne Certbot

Le challenge DNS

Le challenge DNS consiste à créer un enregistrement DNS sur votre domaine et d'y stocker une chaîne aléatoire de 43 caractères fournie par Cerbot.

Si la valeur de l'enregistrement est conforme à celle fournie, Certbot est en mesure de valider que vous êtes bien le propriétaire du domaine. Il vous délivre alors un certificat valable pendant 3 mois.

Renouvellement du certificat

Quand on installe Cerbot on dispose de la commande certbot-auto à laquelle on passe en paramètre:

La commande complète est la suivante:

sudo /usr/local/bin/certbot-auto -d *.pasgrave.net \
--manual --preferred-challenges \
dns certonly

La commmande certbot-auto ouvre un shell intéractif qui vous demande de créer l'enregistrement et de valider une fois la tâche effectuée.

Voici par exemple le type de message envoyé dans la console:

Please deploy a DNS TXT record under the name
_acme-challenge.pasgrave.net with the following value:

x9maSaUJ6HEmEjuKGzS7VMFTLuC0aUskyQw3myLMuVE

Before continuing, verify the record is deployed.
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Press Enter to Continue

Une fois l'enregistrement DNS en place vous appuyez sur Entrée. Si la vérification aboutie Cerbot installe le nouveau certificat.

Automatisation

Automatiser la création d'un enregitrement DNS

Gandi fourni une API http de gestion d'un domaine. Parmis les requètes disponibles il est possible de créer un enregistrement de la façon suivante:

curl -H 'Content-Type: application/json' \
-H 'Authorization: Apikey xxxxxxxxxxxxxx' \
-X PUT -d {"rrset_values" : ["123"]} \
https://api.gandi.net/v5/livedns/domains/pasgrave.net/records/mon_record/TXT

J'ai automatisé de cette partie avec le code suivant:

import requests

my_gandi_key=open("gandi_key", "r").read()
api_url="https://api.gandi.net/v5/livedns/domains/pasgrave.net/records/{}/TXT"

def put_txt_record(name, value):
  url=api_url.format(name)
  print(url)
  data={"rrset_values":[value]}
  print(data)
  headers = {'Content-Type': 'application/json', 'Authorization': my_gandi_key}
  print(headers)
  return requests.put(url, json=data, headers=headers)

Automatisation de la commande Cerbot

On peut interagir avec la commande certbot-auto en utilisant Popen. Il faut noter que le paramètre universal_newlines=True est nécessaire dans notre cas.

Lors de l'initialisation de Popen le programme démarre et on entre dans la boucle while out. A chaque itération on lit le contenu de la ligne et on retourne la réponse appropriée.

Par exemple, quand cerbot retourme une ligne correspondant à la regex ^([^:\s]{43})\n$ nous sommes dans le cas du challenge DNS. On récupère la valeur de l'enregistrement (certkey = match_challenge.group(1)) et on l'envoie à Gandi avec la fonction gandi_http.put_txt_record.

Il ne reste plus alors qu'à appuyer sur entrée (p.stdin.write('\n')) pour laisser Cerbot valider le challenge et nous fournir un nouveau certificat.

Voici le code complet du programme également disponible sur framagit.

from subprocess import Popen, PIPE
import re
import gandi_http

SOUS_DOMAIN="dev"
DOMAIN="pasgrave.net"
DNS_RECORD="_acme-challenge.%s" % SOUS_DOMAIN
MODE_TEST=True

dry_run = " --dry-run" if MODE_TEST else ""
command="/usr/local/bin/certbot-auto -d {} --manual --preferred-challenges dns certonly %s" % dry_run
command=command.format("{}.{}".format(SOUS_DOMAIN, DOMAIN))
p = Popen(command, shell=True, stdin=PIPE, stdout=PIPE, bufsize=0, universal_newlines=True)

challenge_pattern=re.compile("^([^:\s]{43})\n$")

out = p.stdout.readline()
while out:
  line = out
  match_challenge = challenge_pattern.match(line)
  if "Are you OK with your IP being logged" in line:
      p.stdin.write('Y\n')
      out = p.stdout.readline()
      continue
  elif match_challenge:
      certkey = match_challenge.group(1) 
      response = gandi_http.put_txt_record(DNS_RECORD, certkey)
      if response.ok:
        print("Successful update DNS record %s with value %s" % (DNS_RECORD, certkey))
      else:
        print("Fail to save DNS record %s with value %s" % (DNS_RECORD, certkey))

      p.stdin.write('\n')
      out = p.stdout.readline()
      continue
  else:
      print("++%s" % line.rstrip("\n"))
      out = p.stdout.readline()
      continue

p.wait()