Certificats TLS Let'S Encrypt sans serveur web via DNS-01 et l'API OVH


Le contexte

Faisant de l'auto hébergement, je souhaitait pouvoir générer des certificats TLS pour des services qui ne sont pas exposés en HTTP (ex. SMTP, IMAP, libvirt, ...).
Utilisant Let's Encrypt comme fournisseur TLS et OVH comme registrar DNS, j'ai voulu trouver un moyen d'automatiser la génération de certificats TLS Let's Encrypt en utilisant la validation DNS-01 automatiquement avec l'API OVH via Certbot.

Pour les impatients, le plugin est sur mon repo git publique

Validation DNS-01

Le protocole ACME définit plusieurs moyen de valider que le client (moi ^^) est bien habilité a générer des certificats TLS pour son nom de domaine :

  • HTTP-01 : Qui valide l’enregistrement DNS en faisant une requête HTTP sur l’enregistrement DNS
  • DNS-01 : Qui valide l’enregistrement DNS en faisant une requête DNS sur un enregistrement DNS de type TXT de la zone DNS parente

En gros il faut être en mesure de créer un enregistrement DNS de type TXT dans la zone DNS parente puis de le supprimer une fois la validation effectuée.
Le soucis est que je n'ai pas trouvé d'implémentation permettant nativement de piloter l'API OVH pour une validation DNS-01, j'ai donc pris la décision de le coder moi même.

L'implémentation

Personnellement, j'ai opté pour l'implémentation cliente certbot que je trouve simple, complet (ex. hooks de déploiement) et facile à étendre, comme nous allons le voir par la suite...
C'est du python, et c'est remplie d'interfaces pour étendre par plugins ou ajouter des fournisseurs technologiques, que du bonheur.

Certbot est fournit avec une très bonne documentation est j'ai très vite trouvé mon bonheur dans la section plugins.dns_common et la classe de base DNSAuthenticator.

Le plugin

Il y a 3 fonctions de bases à implémenter :

  • _setup_credentials : Qui permet de demander, éventuellement de manière interactive, les identifiants/mot de passe nécessaires. Étant donné que je veux que ce soit entièrement automatique et que le client OVH embarque tout ce qu'il faut (fichier de configuration pour les identifiants nécessaires), je ne l'ai pas implémenté
  • _perform : Appelé pour chaque CN/SAN, c'est cette fonction qui doit implémenter tout les prérequis à la validation (création des enregistrements DNS de validation)
  • _cleanup : La même chose mais dans le sens inverse, cette fonction permettra de nettoyer les enregistrement DNS de validation.

La base de notre plugin va donc ressembler à ça :

"""DNS Authenticator for OVH."""
import logging

import zope.interface

from certbot import errors
from certbot import interfaces
from certbot.plugins import dns_common

logger = logging.getLogger(__name__)

@zope.interface.implementer(interfaces.IAuthenticator)
@zope.interface.provider(interfaces.IPluginFactory)
class Authenticator(dns_common.DNSAuthenticator):
    """DNS Authenticator for OVH

    This Authenticator uses the OVH API to fulfill a dns-01 challenge.
    """


    def _setup_credentials(self):
        pass


    def _perform(self, domain, validation_name, validation):
        """
        Performs a dns-01 challenge by creating a DNS TXT record.

        :param str domain: The domain being validated.
        :param str validation_domain_name: The validation record domain name.
        :param str validation: The validation record content.
        :raises errors.PluginError: If the challenge cannot be performed
        """
        raise NotImplementedError()

    def _cleanup(self, domain, validation_name, validation):
        """
        Deletes the DNS TXT record which would have been created by `_perform_achall`.

        Fails gracefully if no such record exists.

        :param str domain: The domain being validated.
        :param str validation_domain_name: The validation record domain name.
        :param str validation: The validation record content.
        """
        raise NotImplementedError()

Les fonctions prennent le CN/SAN à valider (paramètre "domain"), le nom de l'enregistrement DNS (paramètre "validation_domain_name") et la valeur de l'enregistrement DNS (paramètre "validation") en arguments. Il va falloir s'en servir pour générer des enregistrements DNS dans nos zone via l'API DNS d'OVH.
Il nous faut donc un client de l'API OVH afin de gérer les enregistrement DNS ...

Le client OVH

Avant d'utiliser l'API OVH, il faudra vous créer une clé d'application à l'adresse suivante : https://eu.api.ovh.com/createApp/ et l'installer (ex. via pip : pip install ovh).

Enfin il faudra lui fournir une configuration, notamment les identifiants (la clé créée juste avant). Comme déjà dit, je ne veux pas avoir à rentrer ces identifiants manuellement à chaque renouvellement, il sera donc nécessaire de les avoir en local dans un fichier de configuration ou dans le code source.
Personnellement j'ai créer le fichier /etc/ovh.conf avec le contenu suivant :

[default]
; general configuration: default endpoint
endpoint=ovh-eu

[ovh-eu]
; configuration specific to 'ovh-eu' endpoint
; you can retrieve keys/tokens here: https://api.ovh.com/createToken/
application_key=<YOUR_APPLICATION_KEY>
application_secret=<YOUR_APPLICATION_SECRET>
consumer_key=<YOUR_CONSUMER_KEY>

Veiller à remplacer les variables entre "<>" par vos identifiants. Pensez aussi à positionner des droits restreints sur le fichier (chmod 600 /etc/ovh.conf).

Bon récapitulons, je vais avoir besoin de créer des enregistrement DNS et de les supprimer. J'utiliserais donc les appels API suivants :

La documentation de l'API OVH me dit que j'aurai aussi besoin de rafraichir la zone DNS avec l'appel https://api.ovh.com/console/#/domain/zone/{zoneName}/refresh#POST

Création d'enregistrements

def _add_txt_record(self, domain, record_name, record_content, record_ttl):
    """
    Add a TXT record using the supplied information.
    Wait for the record to be visible in domain NS servers.

    :param str domain: The domain to use to look up the Ovh zone.
    :param str record_name: The record name (typically beginning with '_acme-challenge.').
    :param str record_content: The record content (typically the challenge validation).
    :param int record_ttl: The record TTL (number of seconds that the record may be cached).
    :raises certbot.errors.PluginError: if an error occurs communicating with the Ovh API
    """
    try:
        logger.debug('Attempting to add record to zone {0}: {1} {2} IN TXT {3}'.format(domain, record_name, record_ttl, record_content))
        res = self.ovh.post("/domain/zone/{0}/record".format(domain), fieldType='TXT',
                subDomain=record_name, target=record_content, ttl=record_ttl)
    except ovh.exceptions.APIError as e:
        logger.error('Encountered OVH APIError adding TXT record: {}'.format(e))
        raise errors.PluginError('Error communicating with the Ovh API: {0}'.format(e))
    record_id = res['id']
    logger.debug('Successfully added TXT record with record_id: {}'.format(record_id))
    self._refresh_zone(domain)
    self._waitfor_record(domain, record_name, record_content, 10, 30)

Suppression d'enregsitrements

def _del_txt_record(self, domain, record_name, record_content):
    """
    Delete a TXT record using the supplied information.

    Note that both the record's name and content are used to ensure that similar records
    created concurrently (e.g., due to concurrent invocations of this plugin) are not deleted.

    Failures are logged, but not raised.

    :param str domain: The domain to use to look up the Ovh zone.
    :param str record_name: The record name (typically beginning with '_acme-challenge.').
    :param str record_content: The record content (typically the challenge validation).
    """
    records = self.ovh.get("/domain/zone/{0}/record".format(domain), fieldType='TXT', subDomain=record_name)
    if len(records) < 1:
        raise Exception("No record found for {0}".format(record_name))
    if len(records) > 1:
        raise Exception("Too many record found for {0}. Please clean your DNS".format(record_name))
    logger.debug(" + Deleting TXT record name: {0}".format(record_name))
    self.ovh.delete('/domain/zone/{0}/record/{1}'.format(domain, records[0]))

Refresh de zone

def _refresh_zone(self, domain):
    """
    Refresh the DNS zone

    :param str domain: The domain to refresh
    """
    self.ovh.post('/domain/zone/{0}/refresh'.format(domain))
    logger.info("+ Zone refreshed on OVH side")
    soa = self.ovh.get('/domain/zone/{0}/soa'.format(domain))
    logger.debug("+ SOA SERIAL of zone: {0}".format(soa['serial']))

La zone DNS parente ou La subtilité de la force brute

Chez OVH, on ne gère que des zone DNS de deuxième niveau (SLD : Secondary level domain) comme "openwebzone.fr" ou "acme.co.uk". Mais le protocole ACME est prévu pour aussi fonctionner avec des délégations de zone et je dois donc trouver un moyen de retrouver la zone parente géré par OVH.

Bon bah brute force hein ... je test l'ensemble des zones possible jusqu'à ce qu'OVH me dise qu'il la reconnait :

def _get_zone_from_fqdn(self, fqdn):
    """
    Return the ovh managed dns zone for the given FQDN

    :param str domain: The FQDN
    :return str zone: The dns zone
    """
    zone = None
    parts = fqdn.split('.')
    for i in range(len(parts)):
        z = '.'.join(parts[i:])
        try:
            res = self.ovh.get('/domain/zone/{0}'.format(z))
        except ovh.exceptions.ResourceNotFoundError:
            logger.debug('get zone from fqdn {}: zone {} not managed in ovh'.format(z, fqdn))
            continue
        except ovh.exceptions.APIError as e:
            logger.error('Encountered OVH APIError trying to get zone {} from fqdn {}: {}'.format(z, fqdn, e))
            raise errors.PluginError('Error while trying to detect zone')
        logger.debug('OVH Managed zone found for fqdn {}: {}'.format(fqdn, z))
        zone = z
        break
    if zone is None:
        raise errors.PluginError('No OVH managed zone found for {}'.format(fqdn))
    return z

Pré-Vérification des enregistrements DNS avec la librairie dns python

Un enregistrement DNS peut mettre un peu de temps à être déployé après l'appel à l'API. Pour éviter les problèmes, une vérification de la présence de l'enregistrement dans la zone via un resolveur classique avant de demander à Let'sEncrypt de faire la même chose semble une bonne idée.

J'ai donc décidé d'utiliser la librairie python dns.resolver et d'implémenter un mécanisme de vérification d'enregistrement DNS :

def _waitfor_record(self, domain, record_name, record_content, check_interval, check_limit):
    """
    Wait for the visibility of the record in NS server

    :param str domain: The DNS zone
    :param str record_name: The DNS record name
    :param str record_content: The DNS record content
    :param int check_interval: Sleep time between check
    :param int check_limit: Limit of check loops before raising exception
    """
    nameservers = dns.resolver.query(domain, 'NS')
    resolver = dns.resolver.Resolver()
    resolver.nameservers = []
    resolver.timeout = 3
    resolver.lifetime = 5
    for ns in nameservers:
        addresses = socket.getaddrinfo(ns.to_text(), 53, 0, 0, socket.IPPROTO_TCP)
        for family, socktype, proto, canonname, sockaddr in addresses:
        resolver.nameservers.append(sockaddr[0])
    c=0
    while c < check_limit:
        logger.debug("Testing DNS record against " + ', '.join(resolver.nameservers))
        txt_values = []
        try:
            txt_records = resolver.query('{}.{}'.format(record_name, domain), 'TXT')
            for txt_record in txt_records:
                txt_values.append(txt_record.to_text())
            for txt_value in txt_values:
                if record_content in txt_value:
                logger.info("! Record {} found with matching value: {}".format(record_name, record_content))
                return
        except dns.resolver.NXDOMAIN:
            logger.info(" + Record not available yet. Checking again in {}s...".format(check_interval))
        except dns.exception.Timeout:
            logger.info(" + DNS Request timeout. Checking again in {}s...".format(check_interval))
        logger.debug("+ Got: " + str(', '.join(txt_values)) + " /  Expecting: " + str(record_content))
        time.sleep(check_interval)
    raise Exception('Timeout waiting for record visibility in domain NS. Domain: {} - Record: {} - Value: {}'.format(domain, record_name, record_value))

Implémentation complète

Vous trouverez l'implémentation complète sur mon repo git publique

Installation

Personnellement, j'ai mis tout ça dans un virtualenv python. * Cloner mon repo : git clone git@git.openwebzone.fr:gael/certbot-dns-ovh.git * Compiler et installer : python setup.py install

Utilisation

Personellement, j'ai globalement configuré certbot dans le fichier /etc/letsencrypt/cli.ini pour utiliser mon plugin de validation :

# use dns-01
preferred-challenges = dns
# use dns-ovh authenticator
authenticator = certbot-dns-ovh:dns-ovh

En suite, un simple appel à certbot certonly suffit :
certbot certonly -d smtp.openwebzone.fr