An English version of this post is available.

Moby Dock, la baleine mascotte de Docker

Je met un point d'honneur à auto-héberger mes services de manière sécurisée. En conséquence, je voulais publier le contenu de mon site chiffré par TLS.

Comme je ne voulais pas payer un prix exorbitant pour un certificat pour mon domaine, j'étais plutôt enclin à utiliser l'Autorité de Certification gratuite Let's Encrypt.

Obtenir un certificat Let's Encrypt

Pour obtenir un certificat Let's Encrypt, l'autorité de certification (AC) de Let's Encrypt a besoin que nous prouvions que le domaine pour lequel on souhaite un certificat nous appartient bien.

Il y a différentes façons de prouver à l'AC que je contrôle lenain.info. J'ai choisi de provisionner des ressources HTTP avec une URI connue de l'AC sous ce domaine.

Pour celà, j'ai fait pointer mon DNS vers mon IP publique et j'ai transféré le traffic HTTP et HTTPS vers mon serveur à la maison.

Les prochaines parties de ce post mentionnent example.com plutôt que lenain.info.

Le protocole ACME

Le protocole ACME fonctionne avec un agent Let's encrypt, l'AC Let's encrypt, et plusieurs étapes pour s'assurer que l'on contrôle le domaine DNS :

  • L'agent génère un paire de clés cryptographiques.
  • Il demande alors à l'AC d'utiliser une ressource HTTP comme moyen de vérification.
  • L'AC fourni un challenge à l'agent pour qu'il le publie comme ressource HTTP à une URI connue de l'AC, servie sur le domaine que l'on souhaite valider.
  • L'AC fourni un nonce que l'agent doit signer pour prouver qu'il contrôle la paire de clés.
  • L'agent publie le challenge et le nonce signé, puis demande à l'AC de les vérifier.
  • Une fois que l'AC a téléchargé le challenge et vérifié le nonce, la clé publique de l'agent est autorisée à gérer des certificats.

Servir les challenges

Comme on va utiliser l'agent certbot de l'EFF, créons un répertoire qui contiendra toutes nos données Let's Encrypt sur le serveur.

mkdir -p /home/lenain/letsencrypt/data/certbot/{conf,www}

On utilise alors la configuration nginx suivante pour servir les challenges Let's Encrypt qui seront créés par le certbot :

server {
  listen 80;
  server_name example.com;

  location /.well-known/acme-challenge/ {
    root /var/www/certbot;
  }

  location / {
    root   /usr/share/nginx/html;
    index  index.html index.htm;
  }
}

La prochaine étape est de faire tourner un nginx via Docker pour servir le contenu du répertoire en utilisant ce fichier de configuration.

docker run -v "$(pwd)"/nginx.conf:/etc/nginx/conf.d/default.conf:ro \
  -v "$(pwd)"/data/certbot/www:/var/www/certbot:ro \
  -p 80:80 \
  nginx

Maintenant que le contenu du répertoire est publié, commençons le processus ACME.

Générer le certificat

On démarre certbot, lui aussi avec Docker, sur la machine recevant le trafic HTTP :

docker run -v "$(pwd)"/data/certbot/conf:/etc/letsencrypt \
  -v "$(pwd)"/data/certbot/www:/var/www/certbot \
  --entrypoint "certbot" \
  certbot/certbot \
    certonly \
      --webroot /var/www/certbot \
      --non-interactive \
      --staging \
      --email 'email@example.com' \
      --no-eff-email \
      --domains example.com --domains blog.example.com \
      --rsa-key-size 4096 \
      --agree-tos \
      --force-renewal

On monte en tant que volumes Docker la conf certbot et le répertoire www avec les droits d'écriture pour récupérer les certificats générés et les challenges reçus par certbot.

On indique:

  • --webroot: Le répertoire racine où les challenges et les nonces seront stockés par certbot
  • --noninteractive: On ne veut pas intéragir manuellement avec certbot
  • --staging: On veut utiliser le serveur de test de Let's Encrypt (Une fois que l'on est prêt à généré notre certificat final, on enlèvera ce flag)
  • --email: L'e-mail utilisé pour l'enregistrement et de contact.
  • --no-eff-email: Ne pas partager l'adresse e-mail avec l'EFF.
  • --domains: Les noms de domaines à soumettre, le premier sera utilisé pour le Common Name du certificat.
  • --rsa-key-size: Utiliser une clé RSA de 4096 bits.
  • --agree-tos: Approuver les termes de souscription au service du serveur ACME
  • --force-renewal: Si un certificat existe déjà pour le domaine, le renouveler immédiatement.

Maintenant dans le répertoire certbot/conf/live/example.com/ on a le fichier de la clé privée privkey.pem pour le certificat et le fichier de chaîne complète fullchain.pem contenant tous les certificats incluant celui du serveur.

On peut maintenant benner la configuration nginx et les conteneurs Docker car nous n'en avons plus besoin désormais.

Servir le site web avec Docker Swarm

Architecture

Pour servir le site web, on va utiliser des services Docker Swarm.

On aura 2 services :

  • Un service nginx, qui continuera de servir le répertoire www de certbot.
  • Un autre service nginx, celui-ci servira le contenu de notre site web et communiquera avec le premier.

Créer l'overlay réseau du Docker Swarm

Pour permettre aux deux services nginx de communiquer, on crée un overlay réseau dans le Docker Swarm:

docker network create -d overlay --attachable onsen-naitwaurk

On permet au réseau d'être attachable si on a besoin de lancer des conteneurs qui devraient communiquer avec d'autres conteneur tournant sur d'autres démons Docker.

Le service well-known

Comme l'on veut automatiser le renouvellement du certificat Let's Encrypt, on a besoin d'un moyen de servir les challenges et les nonces Let's Encrypt comme on l'ait fait lors de la génération de notre certificat.

On crée donc un service Docker Swarm qui publiera un endpoint well-known.

On crée un répertoire pour gérer la configuration de ce service :

mkdir -p /srv/docker/onsen-naitwaurk/letsencrypt-well-known

On y crée alors un fichier de configuration nginx.conf :

server {
  listen 80;
  server_name well-known;

  location /.well-known/acme-challenge/ {
    root /var/www/certbot;
  }
}

Et un fichier Docker Compose docker-compose.yml :

version: "3.7"
services:
  well-known:
    image: "nginx:alpine"
    volumes:
      - type: "bind"
        source: /srv/docker/onsen-naitwaurk/letsencrypt-well-known/nginx.conf
        target: /etc/nginx/conf.d/default.conf
        read_only: true
      - type: "bind"
        source: /home/lenain/letsencrypt/data/certbot/www
        target: /var/www/certbot
        read_only: true
    networks:
      - onsen-naitwaurk
    deploy:
      placement:
        constraints:
          - node.labels.letsencrypt == true
networks:
  onsen-naitwaurk:
    external: true
  • Ce service sera appelé "well-known".
  • Il fera usage de la dernière image Docker nginx du projet Alpine Linux.
  • On monte le fichier de configuration nginx nouvellement créé à son chemin par défaut dans le conteneur en lecture seule.
  • On monte aussi en lecture seule le répertoire www de certbot.
  • On indique au service d'utiliser le réseau Docker Swarm onsen-naitwork.
  • Enfin, on met une contrainte de placement sur le service pour qu'il ne tourne que sur le nœud du Docker Swarm ayant les données Let's Encrypt.
  • On n'expose aucun port vers ce service. Cela sera le rôle du service du site.

Sur le nœud du Docker Swarm ayant les données Let's Encrypt on indique un label pour la contrainte de placement :

$ docker node update --label-add letsencrypt=true kawaii

On peut maintenant démarrer le service avec Docker Compose:

$ docker stack deploy --compose-file docker-compose.yml letsencrypt-well-known

Et vérifier que le service est démarré et accessible :

$ docker service ps letsencrypt-well-known_well-known
ID                  NAME                                  IMAGE               NODE                DESIRED STATE       CURRENT STATE        ERROR               PORTS
uyk2nue3nsim        letsencrypt-well-known_well-known.1   nginx:alpine        kawaii              Running             Running 8 days ago

Le service du site

Comme on veut que ce service soit distribué au sein du Docker Swarm, on a besoin d'incorporer à la fois la configuration nginx et les certificats Let's Encrypt dans une image Docker que l'on publiera dans notre registry privée.

Créons les dossiers pour stocker la configuration de ce service:

mkdir -p /srv/docker/onsen-naitwaurk/example.com/{tls,public}

Dockerfile

On crée ensuite un Dockerfile:

FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY tls/ /tls/
COPY public/ /usr/share/nginx/html/

RUN ln -sf /dev/stdout /var/log/nginx/access.log && ln -sf /dev/stderr /var/log/nginx/error.log
CMD ["nginx", "-g", "daemon off;"]

Ce Dockerfile:

  • Utilisera la variante alpine d'nginx comme le service well-known précédemment décrit.
  • Copiera notre configuration nginx comme celle par défaut du conteneur.
  • Copiera nos données TLS dans le dossier /tls.
  • Copiera notre contenu publique dans le dossier par défaut d'nginx.
  • Créera des liens symboliques pour permmetre à nginx de loguer sur stdout et stderr.
  • Démarrera le serveur nginx.

Configuration Nginx

On crée le fichier de configuration nginx.conf:

server {
  listen 80;
  server_name example.com;

  location / {
    return 301 https://$host$request_uri;
  }

  location /.well-known/acme-challenge/ {
     resolver 127.0.0.11 valid=10s;
     set $endpoint well-known;
     proxy_pass     http://$endpoint;
     proxy_redirect off;
     proxy_set_header Host $host;
     proxy_set_header X-Real-IP $remote_addr;
     proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
     proxy_set_header X-Forwarded-Host $server_name;
  }
}

server {
  listen 443 ssl;
  server_name example.com;

  ssl_certificate         /tls/fullchain.pem;
  ssl_trusted_certificate /tls/fullchain.pem;
  ssl_certificate_key     /tls/privkey.pem;

  location / {
    root   /usr/share/nginx/html;
    index  index.html index.htm;
  }
}

Ok, la configuration mérite que l'on s'y attarde un peu. Ici on a 2 serveurs définis:

Serveur HTTP

Ce serveur écoute sur le port HTTP (80) pour le domaine example.com.

Il sert les requêtes /.well-known/acme-challenge/ en les proxifiant vers le service well-known défini précédemment dans le même réseau Docker Swarm.

On demande à nginx de vérifier toutes les 10 secondes que l'endpoint well-known n'a pas changé dans le réseau du Docker Swarm en demandant au resolveur DNS interne de le résoudre à nouveau.

Enfin, il redirige toutes les autres requêtes vers la version HTTPS du service.

Serveur HTTPS

Ce serveur écoute sur le port HTTPS (443) pour le domaine example.com.

Il utilise le dossier /tls pour trouver la clé privée et la chaîne de certification à utiliser.

Docker Compose

Voici le fichier de configuration docker-compose.yml:

version: '3.7'
services:
  example-com:
    image: "registry.onsen.lan:5000/example_com"
    build: .
    ports:
      - "80:80"
      - "443:443"
    networks:
      - onsen-naitwaurk
    deploy:
      mode: replicated
      replicas: 2
networks:
  onsen-naitwaurk:
    external: true
  • Le service s'appellera "example-com".
  • Il poussera et utilisera l'image que l'on construira avec Docker Compose vers/depuis notre registry privée.
  • Il exposera à la fois le port HTTP et HTTPS sur n'importe quel noeud rendant le service.
  • On fait utiliser au service le réseau Docker Swarm onsen-naitwork.
  • Enfin, on le déploie en mode répliqué avec au moins 2 replicas dans le Swarm.

Maintenant on peut construire et pousser le conteneur dans notre registry privée avec Docker Compose. Mais avant, on copie les données TLS depuis le répertoire de certbot.

$ cp /home/lenain/letsencrypt/data/certbot/conf/live/example.com/fullchain.pem tls/fullchain.pem
$ cp /home/lenain/letsencrypt/data/certbot/conf/live/example.com/privkey.pem tls/privkey.pem
$ docker-compose build
$ docker-compose push

On peut démarrer le service, en transmettant l'authentification de la registry aux agents du Docker Swarm:

$ docker stack deploy --compose-file docker-compose.yml example-com --with-registry-auth

Et enfin on vérifie que le service est démarré et utilisable:

$ docker service ps example-com_example-com
ID                  NAME                            IMAGE                                               NODE                DESIRED STATE       CURRENT STATE         ERROR               PORTS
732k5bd2a140        example-com_example-com.1       registry.onsen.lan:5000/example_com:latest   kawaii              Running             Running 8 days ago
r2tqzevlfq93        example-com_example-com.2       registry.onsen.lan:5000/example_com:latest   kissu               Running             Running 8 days ago

Renouveller automatiquement le certificat

On crée un service certbot dans notre Docker Swarm, il essaiera de renouveler notre certificat Let's Encrypt une fois par jour pour éviter son expiration.

On crée un dossier pour stocker sa configuration :

mkdir -p /srv/docker/onsen-naitwaurk/letsencrypt-renew

On crée un fichier Docker Compose docker-compose.yml :

version: "3.7"
services:
  renew:
    image: "certbot/certbot"
    volumes:
      - type: "bind"
        source: /root/letsencrypt/data/certbot/conf
        target: /etc/letsencrypt
        read_only: false
      - type: "bind"
        source: /root/letsencrypt/data/certbot/www
        target: /var/www/certbot
        read_only: false
    entrypoint:
      - /bin/sh
      - -c
      - 'trap exit TERM; while true; do certbot renew; sleep 1d & wait $${!}; done;'
    deploy:
      placement:
        constraints:
          - node.labels.letsencrypt == true
  • Le service s'appellera "renew".
  • Il utilisera la dernière image Docker certbot.
  • On monte en lecture/écriture le dossier de conf de certbot pour lui permettre d'y écrire ses nouveaux certificats et paires de clés.
  • On monte aussi en lecture/écriture le dossier www pour que certbot y écrive ses challenges et nonces.
  • On configure une contrainte de placement sur le service pour qu'il ne tourne que sur le nœud du Docker Swarm ayant les données Let's Encrypt.
  • On modifie l'entrypoint pour ne lancer la commande certbot renew qu'une fois par jour.

On démarre le service :

docker stack deploy --compose-file docker-compose.yml letsencrypt-renew

On vérifie qu'il est fonctionnel :

$ docker service ps letsencrypt-renew_renew
ID                  NAME                        IMAGE                    NODE                DESIRED STATE       CURRENT STATE        ERROR               PORTS
ilq8u259hmwp        letsencrypt-renew_renew.1   certbot/certbot:latest   kawaii              Running             Running 8 days ago

On peut aussi vérifier les logs du renouvellement du certificat :

$ docker service logs letsencrypt-renew_renew
[...]
letsencrypt-renew_renew.1.ilq8u259hmwp@kawaii    | - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
letsencrypt-renew_renew.1.ilq8u259hmwp@kawaii    |
letsencrypt-renew_renew.1.ilq8u259hmwp@kawaii    | The following certs are not due for renewal yet:
letsencrypt-renew_renew.1.ilq8u259hmwp@kawaii    |   /etc/letsencrypt/live/example.com/fullchain.pem expires on 2020-02-14 (skipped)
letsencrypt-renew_renew.1.ilq8u259hmwp@kawaii    | No renewals were attempted.
letsencrypt-renew_renew.1.ilq8u259hmwp@kawaii    | - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

Quelques réflexions pour finir

Maintenant mon site web est distribué au sein de mon Docker Swarm et chiffré avec HTTPS.

Cependant, il y a encore des améliorations à faire :

  • Si mon nœud Let's Encrypt est indisponible je ne pourrais par renouveler mon certificat car les services certbot et well-known seront indisponibles (et, pire: cela en serait de même pour ma registry privée !).
  • Même si le certificat est renouvelé automatiquement, je dois quand même reconstruire l'image Docker de mon service pour y inclure les nouveaux certificats et mettre à jour le service du Docker Swarm.
  • Je dois encore copier manuellement les certificats, clés et chaînes depuis le dossier des données Let's Encrypt vers le dossier tls de mon service pour le site avant de reconstruire l'image. Tout ce processus de build doit alors être exécuté depuis le nœud ayant les données Let's Encrypt...