Ansibled - DNS, proxy et terminaison TLS pour vos applications

Ansibled - DNS, proxy et terminaison TLS pour vos applications

La rapidité et la simplicité qu’apportent les fournisseurs de cloud public en terme de déploiement applicatif n’est plus à démontrer. Il s’agit bien souvent de l’argument principal à leur adoption. Cependant, il reste encore de nombreux cas où l’utilisation d’un cloud public n’est pas envisageable. Que ce soit pour des questions de souveraineté de la donnée (hébergement de données médicales, bancaires, etc...), ou tout autre contrainte propre à une entreprise. Dans ce genre de cas il est nécessaire de disposer et d’exploiter ses propres infrastructures. La question qui se pose alors est de savoir comment atteindre un niveau de réactivité et de rapidité de déploiement, comparable à ce que l’on peut avoir sur du cloud public.

Nous allons prendre le cas simple d’une application web et montrer comment déployer on-premise, avec ansible :

  • une zone DNS (publique)
  • les entrées DNS (publiques) nécessaires pour exposer cette application et les suivantes
  • un serveur proxy, qui donnera accès à l’application et assurera l’accès en HTTPS pour les clients
  • un certificat TLS signé par une autorité de certification reconnue, sans frais additionnels

schema

NB: les configurations présentées ici sont insuffisantes pour un déploiement en production, elles n’intègrent notamment aucun élément lié à la haute disponibilité. Il s’agit d’un exemple, qu’il convient d’étendre et d’adapter pour un déploiement sérieux.

L’outillage

Pour arriver à nos fins, nous allons utiliser les outils suivants:

  • nginx
  • letsencrypt: qui est à la fois une autorité de certification et un service de distribution de certificats TLS signés, par API
  • knot DNS: un serveur DNS faisant autorité, réputé pour ses performances et sa simplicité de configuration
  • ansible: le métronome de notre architecture, un outil de gestion de configuration, certainement le plus efficace et le plus souple du marché, un must-have
  • gandi.net: le registrar et hébergeur français bien connu, qui propose surtout une API bien pratique

DNS

Enregistrement d’un nom de domaine

Partons du postulat que notre application ne dispose même pas de nom de domaine. Comment automatiser également le processus de création de ce nom de domaine ? Si votre registrar est gandi.net, une API existe.

Pour commencer il vous faut générer une clef d’API. Dans votre interface d’administration Gandi, rendez-vous sur la fiche descriptive de votre compte, puis:

  • cliquez sur “Change password & configure access restrictions”
    dans “Sécurité”
  • cliquez sur “Générer la clef d’API”
  • saisissez votre mot de passe et copiez votre clef d’API (dans keepassx ou vault...)

Notez qu’il est possible, via l’interface d’administration, de restreindre l’accès à l’API par adresse IP, pour un peu plus de sécurité.

Une fois votre clef récupérée, il est possible d’automatiser la réservation de votre nom de domaine en utilisant gandi cli.

Exemple:

gandi setup #saisissez votre clef d’API
gandi domain create --domain mydomain.com --duration 1

Pour permettre à notre serveur DNS d’être le nameserver principal du domaine, il est nécessaire d’éditer les glue records. Je n’ai pas trouvé comment le faire avec gandi cli, mais ceci est réalisable sur l’interface d’administration et le sera certainement prochainement par l’API.

Configuration d’un serveur DNS

Première étape: déployer notre serveur DNS et notre zone avec ansible. Pour ce faire, j’ai repris un rôle ansible existant, le résultat est disponible ici.

On appelle le rôle de cette manière, dans un playbook :

- hosts: dns
  roles:
    - knot

Et la configuration s’écrit comme suit dans les host_vars :

############################
## knot DNS configuration

knot_user: knot
knot_group: knot
knot_interfaces:
  - 127.0.0.1
  - "{{ ansible_default_ipv4.address }}"
knot_zones:
  - { name: 'mydomain.com', storage: "{{ knot_install_dir }}/etc/knot/zones", file: "mydomain.com.zone", src_file: 'files/mydomain.com.zone' }

La dernière ligne de configuration permet de déployer le fichier de zone attaché à notre nom de domaine. Voici à quoi ressemble le fichier de zone en question (remplacer l’adresse IP de l’entrée A par l’adresse IP publique par laquelle le proxy est accessible) :

$TTL 1

$ORIGIN mydomain.com.
mydomain.com. 600 IN SOA ns1.mydomain.com. (
  hostmaster.mydomain.com.
  2018082601
  86400
  7200
  604800
  86400
)

$TTL 86400

@       NS      ns1.mydomain.com.

mysuperapp     A    192.0.2.3

Une fois notre serveur dns et notre zone configurée, installons et configurons le proxy.

Configuration du serveur proxy

Nous allons également utiliser un role ansible pour configurer notre serveur proxy. Voici celui que j’ai utilisé: https://github.com/WeScale/Stouts.nginx. Ce rôle permet l’installation et la configuration de base de nginx, mais aussi de créer des vhosts basés sur des templates personnalisés. On écrit donc le template (externe au rôle) qui convient pour notre application et qui comprend la configuration nécessaire à la validation et à la récupération de notre futur certificat TLS (le dossier .well-known/acme-challenge) :

server {
    listen 80;
    listen [::]:80;
    server_name {{ item.name }} ;
    access_log /var/log/nginx/{{ item.name }}_access.log;
    error_log /var/log/nginx/{{ item.name }}_error.log;
    root /var/www/{{ item.name }};
    location / {
        rewrite ^/(.*)$ https://$host$request_uri;
    }
    location /.well-known/acme-challenge/ {
        alias /var/www/{{ item.name }}/.well-known/acme-challenge/;
        allow all;
    }
}
server {
    listen 443;
    listen [::]:443;
    server_name {{ item.name }};
    access_log /var/log/nginx/{{ item.name }}_access.log;
    error_log /var/log/nginx/{{ item.name }}_error.log;
    ssl on;
    ssl_certificate {{ letsencrypt_path }}/{{ item.name }}/{{ item.name }}.crt;
    ssl_certificate_key {{ letsencrypt_path }}/{{ item.name }}/{{ item.name }}.key;
    ssl_protocols      TLSv1.1 TLSv1.2;
    ssl_ciphers kEECDH+ECDSA:kEECDH:kEDH:HIGH:SHA256:!RC4:!aNULL:!eNULL:!LOW:!3DES:!MD5:!EXP:!DSS:!PSK:!SRP:!CAMELLIA;
    ssl_prefer_server_ciphers on;
    location /.well-known/acme-challenge/ {
        alias /var/www/{{ item.name }}/.well-known/acme-challenge/;
        allow all;
    }

    root {{ webroot }}/{{ item.name }};
}

Et on configure le rôle comme suit, dans les host_vars :

domains:
  - { "name": "mysuperapp.mydomain.com", "renew": True, "template": "templates/nginx_vhost.j2" }

nginx_templatized_vhosts: "{{ domains }}"

Avant de faire appel à letsencrypt, il est préférable de fournir à nginx des certificats tls utilisables, pour valider le bon fonctionnement du vhost. De plus, puisque nous allons utiliser la validation par http pour letsencrypt, nous avons besoins que ce vhost soit fonctionnel pour permettre le processus de validation. Notez que, puisque nous avons la maîtrise des entrées DNS de notre domaine, nous pourrions également effectuer une validation par entrée DNS.

Voici donc le playbook de déploiement de nginx (toujours en utilisant les host_vars que nous avons définis), concentrons-nous sur les pre_tasks :

- hosts: proxy
  pre_tasks:
    - name: ensure web directories exist
      file:
        state: directory
        path: "{{ webroot }}/{{ item.name }}"
        owner: www-data
        group: www-data
      with_items:
        - "{{ domains }}"
    - name: ensure letsencrypt paths exists
      file:
        state: directory
        path: "{{ letsencrypt_path }}/{{ item.name }}"
      with_items:
        - "{{ domains }}"
    - name: prepare tls private keys
      shell: "! [ -e {{ letsencrypt_path }}/{{ item.name }}/{{ item.name }}.key ] && openssl genrsa -out {{ letsencrypt_path }}/{{ item.name }}/{{ item.name }}.key 4096"
      with_items:
        - "{{ domains }}"
      ignore_errors: True
    - name: prepare account keys
      shell: "! [ -e {{ letsencrypt_path }}/{{ item.name }}/{{ item.name }}_account.key ] && openssl genrsa -out {{ letsencrypt_path }}/{{ item.name }}/{{ item.name }}_account.key 4096"
      with_items:
        - "{{ domains }}"
      ignore_errors: True
    - name: prepare tls CSRs
      shell: "openssl req -new -key {{ letsencrypt_path }}/{{ item.name }}/{{ item.name }}.key -subj \"/C=FR/ST=IDF/L=Paris/O=mydomain.com/OU=Hosting services/CN={{ item.name }}\" -out {{ letsencrypt_path }}/{{ item.name }}/{{ item.name }}.csr"
      when: "item.renew is defined and item.renew"
      with_items:
        - "{{ domains }}"
    - name: generate auto-signed certificate if it doesn't exist
      shell: "! [ -e {{ letsencrypt_path }}/{{ item.name }}/{{ item.name }}.crt ] && openssl x509 -req -days 365 -in {{ letsencrypt_path }}/{{ item.name }}/{{ item.name }}.csr -signkey {{ letsencrypt_path }}/{{ item.name }}/{{ item.name }}.key -out {{ letsencrypt_path }}/{{ item.name }}/{{ item.name }}.crt"
      with_items:
        - "{{ domains }}"
      ignore_errors: True
  roles:
    - nginx
  post_tasks:
    - service:
        name: nginx
        state: reloaded

Nous nous assurons ici, de créer un certificat CA et un certificat TLS auto signés, qui ne seront pas valables aux yeux des clients, mais permettront de faire fonctionner le vhost lors de son déploiement initial et ainsi servir en http les fichiers nécessaires à letsencrypt (voir le bloc location “location /.well-known/acme-challenge/” dans le template du vhost) pour la validation du vrai certificat.

Configuration TLS: letsencrypt

Une fois le serveur DNS et le proxy configurés il nous faut récupérer le certificat de la part de letsencrypt. Voici un schéma du déroulement de la procédure :

archi

Dans un premier temps nous enverrons une requête à Letsencrypt pour s’authentifier et demander un challenge pour notre nom de domaine. On copie les données du challenge dans un dossier desservi par notre proxy. Puisque l’entrée DNS de notre application est accessible, le service pourra alors accéder de nouveau à notre serveur pour vérifier la présence et le bon contenu du fichier de validation. Si le challenge est validé nous récupérerons le certificat TLS final.

Voici le rôle ansible utilisé: https://github.com/WeScale/ansible-role-letsencrypt.

Ce rôle utilise également le dictionnaire “domains” de notre fichier host_vars, en l’assignant de cette manière :

letsencrypt_domains: “{{ domains }}”

Voyons un peu plus en détail son fonctionnement, en regardant le playbook generate.yml :

---
# tasks file for letsencrypt

- name: ensure well-known directory exists
  file:
    path: "{{ webroot }}/{{ item.name }}/.well-known/acme-challenge"
    state: directory
    recurse: yes
    owner: "{{ letsencrypt_files_owner | default('www-data') }}"
    group: "{{ letsencrypt_files_group | default('www-data') }}"
- letsencrypt:
    account_key: "{{ letsencrypt_path }}/{{ item.name }}/{{ item.name }}_account.key"
    csr: "{{ letsencrypt_path }}/{{ item.name }}/{{ item.name }}.csr"
    dest: "{{ letsencrypt_path }}/{{ item.name }}/{{ item.name }}.crt"
    agreement: "{{ letsencrypt_agreement | default('https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf') }}"
    account_email: "{{ letsencrypt_account_email }}"
    remaining_days: "{{ item.remaining_days | default(365) }}"
    acme_directory: "{{ letsencrypt_acme_directory | default('https://acme-v01.api.letsencrypt.org/directory') }}"
  register: le_challenge
  when: "item.renew is defined and item.renew"
- debug:
    var: le_challenge
- copy:
    dest: "{{ webroot }}/{{ item.name }}/{{ le_challenge['challenge_data'][item.name]['http-01']['resource'] }}"
    content: "{{ le_challenge['challenge_data'][item.name]['http-01']['resource_value'] }}"
    owner: www-data
    group: www-data
  when: le_challenge|changed
- letsencrypt:
    account_key: "{{ letsencrypt_path }}/{{ item.name }}/{{ item.name }}_account.key"
    csr: "{{ letsencrypt_path }}/{{ item.name }}/{{ item.name }}.csr"
    dest: "{{ letsencrypt_path }}/{{ item.name }}/{{ item.name }}.crt"
    data: "{{ le_challenge }} "
    agreement: "{{ letsencrypt_agreement | default('https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf') }}"
    account_email: "{{ letsencrypt_account_email }}"
    acme_directory: "{{ letsencrypt_acme_directory | default('https://acme-v01.api.letsencrypt.org/directory') }}"
  register: result
  when: "item.renew is defined and item.renew and le_challenge|changed"

Nous utilisons ici le module ansible pour letsencrypt. La validation se déroule en trois phases :

  • demander les données du challenge letsencrypt à l’API via le module
  • écrire le fichier nécessaire à la validation dans le dossier cible (celui servi par nginx)
  • demander à letsencrypt d’aller récupérer ce fichier pour valider que le domaine nous appartient bien et de nous transmettre le certificat si l’a validation a réussi

Il est nécessaire, pour une bonne configuration https, y inclure le certificat intermédiaire et le root CA letsencrypt. Le playbook intermediate.yml est prévu à cet effet :

- name: get the intermediate certificate
  get_url:
    url: "{{ letsencrypt_intermediate_cert_url }}"
    dest: "{{ letsencrypt_path }}/{{ item.name }}/{{ item.name }}.intermediate.crt"
- name: read the intermediate certificate file
  shell: cat "{{ letsencrypt_path }}/{{ item.name }}/{{ item.name }}.intermediate.crt"
  register: file_content
- name: add intermediate certificate in the certificate file
  blockinfile:
    path: "{{ letsencrypt_path }}/{{ item.name }}/{{ item.name }}.crt"
    block: |
      {{ file_content.stdout }}
    marker: ""
- name: get the root certificate
  get_url:
    url: "{{ letsencrypt_root_ca_certificate }}"
    dest: "{{ letsencrypt_path }}/{{ item.name }}/{{ item.name }}.root.crt"
- name: read the root certificate file
  shell: cat "{{ letsencrypt_path }}/{{ item.name }}/{{ item.name }}.root.crt"
  register: file_content
- name: add root certificate in the certificate file
  blockinfile:
    path: "{{ letsencrypt_path }}/{{ item.name }}/{{ item.name }}.crt"
    block: |
      {{ file_content.stdout }}
    marker: ""

Plusieurs éléments importants sont à prendre en compte concernant l’utilisation de ce module letsencrypt.

Notes importantes concernant le module ansible pour letsencrypt

Premièrement, le paramètre acme_directory désigne le service letsencrypt ciblé, dans le rôle il est mis à https://acme-v01.api.letsencrypt.org/directory qui est le service de production (qui délivre les certificats signés). Le nombre d’appel à ce service est limité à quelques demandes par mois pour un même domaine. Pour effectuer vos tests, je vous recommande donc de ne pas saisir ce paramètre et de laisser la valeur par défaut proposée par le module, qui cible le service de test de letsencrypt. Les certificats délivrés ne sont alors pas valides du point de vue du navigateur, car non signés par l’autorité de certification, mais vous pourrez effectuer autant d’appels et de processus de validation que souhaité pour tester votre configuration.

Deuxièmement, la valeur par défaut du paramètre agreement pointe sur une version obsolète du document, celle-ci est mise à jour au niveau du rôle, mais attention si vous utilisez le module en dehors, les appels à l’API ne fonctionneront pas si vous n’en changez pas la valeur.

Gestion des dépendances

Pour gérer l’installation des rôles dans votre projet, il est possible de déclarer ces dépendances dans un fichier requirements.yml (à l’instar de pip) que ansible pourra interpréter. Voici un exemple correspondant à notre cas :

- src: https://github.com/bpetit/ansible-role-knotauth.git
  name: knot
- src: https://github.com/bpetit/Stouts.nginx
  name: nginx
- src: https://github.com/bpetit/ansible-role-letsencrypt
  name: letsencrypt

Vous pourrez ainsi installer les dépendances via la commande :

ansible-galaxy install -r requirements.yml

Et appeler les rôles avec le nom définit par le champ name.

Conclusion

Nous avons montré comment permettre, avec ansible, le déploiement de l’environnement nécessaire à une application web destinée à être exposée sur Internet: proxy, configuration DNS et terminaison TLS. Cet exemple de configuration est fonctionnel mais nécessite d’être étendu pour gérer la haute disponibilité de l’application. Notez également que la validation letsencrypt par http fonctionne uniquement si l’application est exposée sur Internet. Pour une application interne à votre entreprise par exemple, il est nécessaire de passer par la validation à l’aide d’une entrée DNS prévue à cet effet.

La plus-value de cette configuration est visible une fois que tout est en place: on peut ajouter une entrée DNS, un vhost et un certificat pour une nouvelle application, simplement en éditant les host_vars et en relançant ansible-playbook.