Automatisation: Gérer vos certificat SSL (4/4)

Automatisation: Gérer vos certificat SSL (4/4)

Bon, Nous y sommes.
Nous allons pousser plus loin cette automatisation.
Je garde tout de même la main sur quand je vais déployer mon playbook, puisque je reçois par mail, 30 jours avant, une alerte.

Faisons le bilan.
Nous avons 2 scripts (renommons-les)
tmp2 => check-cert, qui vérifie la date d'expiration des certificats tous les 5 jours via une tache cron et envoi un mail pour les certificats concernés.
tmp3 => manage, qui permet de :
1 - Vérifier l'enregistrement d'un domaine ou d'un sous domaine
2 - Vérifier la date d'expiration des certificats
3 - Générer un certificat Let's encrypt
4 - Générer un certificat OpenSSL
5 - Révoquer un certificat
6 - Supprimer un certificat OpenSSL

Ce deuxième script est surtout pour de l'administration, de l'urgence (révoquer un certificat), ou en créer de nouveau dans le cas d'un nouveau service à mettre en place (le playbook à mettre en place fait partie de cette étape)
Nous ne verrons pas la mise en place d'un nouveau service web mais le playbook sera sensiblement le même, il suffit d'utiliser exclusivement ceci :

---
- name: Fetching toto certificat from cert server
  ansible.builtin.fetch:
    run_once: true
    src: "{{ item.src }}"
    dest: "{{ item.dest }}"
    flat: true
  with_items:
    - { src: "/etc/letsencrypt/archive/toto.fast-reload.com/fullchain1.pem", dest: "/tmp/toto/fullchain1.pem" }
    - { src: "/etc/letsencrypt/archive/toto.fast-reload.com/privkey1.pem", dest: "/tmp/toto/privkey1.pem" }
  when: inventory_hostname == 'cert'

- name: Copying new certificat
  ansible.builtin.copy:
    src: "{{ item.src }}"
    dest: "{{ item.dest }}"
    owner: root
    group: root
    mode: 0777
  with_items:
    - { src: "/tmp/toto/fullchain1.pem", dest: "/etc/letsencrypt/live/toto.fast-reload.com/fullchain.pem" }
    - { src: "/tmp/toto/privkey1.pem", dest: "/etc/letsencrypt/live/toto.fast-reload.com/privkey.pem" }
  when: inventory_hostname == 'toto'

# Not required
- name: Restart service apache2, in all cases
  ansible.builtin.service:
    name: apache2
    state: restarted
  when: inventory_hostname == 'toto'


Le dérouler de la CI (mise à jour du certificat) est simple, elle reprend en grande partie ce que nous avons fait dans nos script bash.

  • Ouvrir les ports 80 & 443
  • Révoquer le certificat
  • Générer un nouveau certificat
  • Fermer les ports 80 & 443
  • Sur le serveur cible, supprimer le vieux certificat
  • Sur le serveur de certificat, récupérer le nouveau certificat
  • Copier le certificat sur le serveur cible
  • Et (non requis) redémarrer le service apache (dans l'exemple)

Ci-dessous une CI simple, qui reprend tout ce que l'on viens de lister.
CI à laquelle il faudra sans doute créer des variables car mettre les domaines en dur cela risque d'être pénible si vous avez plusieurs certificats à gérer.
Ceci s’exécute en quelques secondes, donc la coupure du service est extrêmement limité, un visiteur sur votre site en train de lire une page ne s'en rendrait même pas compte.

Vous noterez la première tâche et les 2 dernières.
Pour faire simple on récupère les dates d'expiration avant et après mais sur le serveur web
sachant que le certificat sera renouvelé sur le serveur cert et,
en dernière tâche on affiche les deux variables stockées.

---
- name: Fetch cert date Before ...
  ansible.builtin.shell: openssl x509 -enddate -noout -in /etc/letsencrypt/live/toto.fast-reload.com/cert.pem | sed 's/notAfter=//'
  register: cert_before
  when: inventory_hostname == 'toto'

- name: Allow IPTABLES connections ...
  ansible.builtin.iptables:
    chain: "{{ item.chain }}"
    protocol: "{{ item.proto }}"
    destination_port: "{{ item.port }}"
    jump: ACCEPT
  with_items:
    - { chain: "INPUT", proto: "tcp", port: "80" }
    - { chain: "INPUT", proto: "tcp", port: "443" }
    - { chain: "OUTPUT", proto: "tcp", port: "80" }
    - { chain: "OUTPUT", proto: "tcp", port: "443" }
  when: inventory_hostname == 'cert'

- name: Revoke old certificat ...
  ansible.builtin.shell: certbot revoke --cert-name "toto.fast-reload.com" --non-interactive
  when: inventory_hostname == 'cert'

- name: Generate new certificat
  ansible.builtin.shell: certbot certonly --standalone -d toto.fast-reload.com --agree-tos --non-interactive --email mon.super@mail.amoi > /dev/null
  when: inventory_hostname == 'cert'

- name: Remove IPTABLES connections ...
  ansible.builtin.iptables:
    chain: "{{ item.chain }}"
    protocol: "{{ item.proto }}"
    destination_port: "{{ item.port }}"
    jump: ACCEPT
    state: absent
  with_items:
    - { chain: "INPUT", proto: "tcp", port: "80" }
    - { chain: "INPUT", proto: "tcp", port: "443" }
    - { chain: "OUTPUT", proto: "tcp", port: "80" }
    - { chain: "OUTPUT", proto: "tcp", port: "443" }
  when: inventory_hostname == 'cert'

- name: Remove old certificat ...
  ansible.builtin.file:
    path: "{{ item.dest }}"
    state: absent
  with_items:
    - { dest: "/etc/letsencrypt/live/toto.fast-reload.com/fullchain.pem" }
    - { dest: "/etc/letsencrypt/live/toto.fast-reload.com/privkey.pem" }
  when: inventory_hostname == 'toto'

- name: Fetching toto certificat from cert server ...
  ansible.builtin.fetch:
    run_once: true
    src: "{{ item.src }}"
    dest: "{{ item.dest }}"
    flat: true
  with_items:
    - { src: "/etc/letsencrypt/archive/toto.fast-reload.com/fullchain1.pem", dest: "/tmp/toto/fullchain1.pem" }
    - { src: "/etc/letsencrypt/archive/toto.fast-reload.com/privkey1.pem", dest: "/tmp/toto/privkey1.pem" }
  when: inventory_hostname == 'cert'

- name: Copying new certificat ...
  ansible.builtin.copy:
    src: "{{ item.src }}"
    dest: "{{ item.dest }}"
    owner: root
    group: root
    mode: 0777
  with_items:
    - { src: "/tmp/toto/fullchain1.pem", dest: "/etc/letsencrypt/live/toto.fast-reload.com/fullchain.pem" }
    - { src: "/tmp/toto/privkey1.pem", dest: "/etc/letsencrypt/live/toto.fast-reload.com/privkey.pem" }
  when: inventory_hostname == 'toto'

# Not required
- name: Restart service apache2, in all cases ...
  ansible.builtin.service:
    name: apache2
    state: restarted
  when: inventory_hostname == 'toto'

- name: Fetch cert date after ...
  ansible.builtin.shell: openssl x509 -enddate -noout -in /etc/letsencrypt/live/toto.fast-reload.com/cert.pem | sed 's/notAfter=//'
  register: cert_after
  when: inventory_hostname == 'toto'

- name: Show Result cert date before & after ...
  ansible.builtin.debug:
    msg:
      - "Expire date before: {{ cert_before.stdout }}"
      - "Expire date after: {{ cert_after.stdout }}"
  when: inventory_hostname == 'toto'

il ne vous reste plus qu'à "variabiliser" vos path et domaines,
Préparer vos job .gitlab-ci.yml, par exemple :

variables:
  LIMIT: "--limit lab_install"

stages:
  - certificat

certificat_update:
  stage: certificat
  image: $CI_REGISTRY_IMAGE/ansible:$VERSION # mon runner est une image 
  script: |
    ansible-playbook -i env/inventory $LIMIT certificat.yml
  when: manual

C'est pas plus compliqué que cela pour la base.
A vous de jouer si vous souhaitez rajouter des rules par exemple.
Ci-dessous le résultat de notre labeur.
(le certificat venait juste d'être générer, ce qui explique la faible différence entre before et after sur la dernière tâche)

PLAY [Lab install] *************************************************************
TASK [Gathering Facts] *********************************************************
[WARNING]: Platform linux on host cert is using the discovered Python
interpreter at /usr/bin/python3.11, but future installation of another Python
interpreter could change the meaning of that path. See
https://docs.ansible.com/ansible-
core/2.16/reference_appendices/interpreter_discovery.html for more information.
ok: [toto]
ok: [cert]
TASK [certificat : Fetch cert date Before ...] *********************************
skipping: [cert]
changed: [toto]
TASK [certificat : Allow IPTABLES connections ...] *****************************
skipping: [toto] => (item={'chain': 'INPUT', 'proto': 'tcp', 'port': '80'}) 
skipping: [toto] => (item={'chain': 'INPUT', 'proto': 'tcp', 'port': '443'}) 
skipping: [toto] => (item={'chain': 'OUTPUT', 'proto': 'tcp', 'port': '80'}) 
skipping: [toto] => (item={'chain': 'OUTPUT', 'proto': 'tcp', 'port': '443'}) 
skipping: [toto]
changed: [cert] => (item={'chain': 'INPUT', 'proto': 'tcp', 'port': '80'})
changed: [cert] => (item={'chain': 'INPUT', 'proto': 'tcp', 'port': '443'})
changed: [cert] => (item={'chain': 'OUTPUT', 'proto': 'tcp', 'port': '80'})
changed: [cert] => (item={'chain': 'OUTPUT', 'proto': 'tcp', 'port': '443'})
TASK [certificat : Revoke old certificat ...] **********************************
skipping: [toto]
changed: [cert]
TASK [certificat : Generate new certificat] ************************************
skipping: [toto]
changed: [cert]
TASK [certificat : Remove IPTABLES connections ...] ****************************
skipping: [toto] => (item={'chain': 'INPUT', 'proto': 'tcp', 'port': '80'}) 
skipping: [toto] => (item={'chain': 'INPUT', 'proto': 'tcp', 'port': '443'}) 
skipping: [toto] => (item={'chain': 'OUTPUT', 'proto': 'tcp', 'port': '80'}) 
skipping: [toto] => (item={'chain': 'OUTPUT', 'proto': 'tcp', 'port': '443'}) 
skipping: [toto]
changed: [cert] => (item={'chain': 'INPUT', 'proto': 'tcp', 'port': '80'})
changed: [cert] => (item={'chain': 'INPUT', 'proto': 'tcp', 'port': '443'})
changed: [cert] => (item={'chain': 'OUTPUT', 'proto': 'tcp', 'port': '80'})
changed: [cert] => (item={'chain': 'OUTPUT', 'proto': 'tcp', 'port': '443'})
TASK [certificat : Remove old certificat ...] **********************************
skipping: [cert] => (item={'dest': '/etc/letsencrypt/live/toto.fast-reload.com/fullchain.pem'}) 
skipping: [cert] => (item={'dest': '/etc/letsencrypt/live/toto.fast-reload.com/privkey.pem'}) 
skipping: [cert]
changed: [toto] => (item={'dest': '/etc/letsencrypt/live/toto.fast-reload.com/fullchain.pem'})
changed: [toto] => (item={'dest': '/etc/letsencrypt/live/toto.fast-reload.com/privkey.pem'})
TASK [certificat : Fetching toto certificat from cert server ...] **************
skipping: [toto] => (item={'src': '/etc/letsencrypt/archive/toto.fast-reload.com/fullchain1.pem', 'dest': '/tmp/toto/fullchain1.pem'}) 
skipping: [toto] => (item={'src': '/etc/letsencrypt/archive/toto.fast-reload.com/privkey1.pem', 'dest': '/tmp/toto/privkey1.pem'}) 
skipping: [toto]
changed: [cert] => (item={'src': '/etc/letsencrypt/archive/toto.fast-reload.com/fullchain1.pem', 'dest': '/tmp/toto/fullchain1.pem'})
changed: [cert] => (item={'src': '/etc/letsencrypt/archive/toto.fast-reload.com/privkey1.pem', 'dest': '/tmp/toto/privkey1.pem'})
TASK [certificat : Copying new certificat ...] *********************************
skipping: [cert] => (item={'src': '/tmp/toto/fullchain1.pem', 'dest': '/etc/letsencrypt/live/toto.fast-reload.com/fullchain.pem'}) 
skipping: [cert] => (item={'src': '/tmp/toto/privkey1.pem', 'dest': '/etc/letsencrypt/live/toto.fast-reload.com/privkey.pem'}) 
skipping: [cert]
changed: [toto] => (item={'src': '/tmp/toto/fullchain1.pem', 'dest': '/etc/letsencrypt/live/toto.fast-reload.com/fullchain.pem'})
changed: [toto] => (item={'src': '/tmp/toto/privkey1.pem', 'dest': '/etc/letsencrypt/live/toto.fast-reload.com/privkey.pem'})
TASK [certificat : Restart service apache2, in all cases ...] ******************
skipping: [cert]
changed: [toto]
TASK [certificat : Fetch cert date after ...] **********************************
skipping: [cert]
changed: [toto]
TASK [certificat : Show Result cert date before & after ...] *******************
ok: [toto] => {
    "msg": [
        "Expire date before: Jun 25 15:53:52 2024 GMT",
        "Expire date after: Jun 25 16:00:15 2024 GMT"
    ]
}
skipping: [cert]
PLAY RECAP *********************************************************************
cert                       : ok=6    changed=5    unreachable=0    failed=0    skipped=6    rescued=0    ignored=0   
toto                       : ok=7    changed=5    unreachable=0    failed=0    skipped=5    rescued=0    ignored=0   
Cleaning up project directory and file based variables

Job succeeded

Fait intéressant, comme je vous l'avais dit, cela ne prend que quelques secondes, un utilisateur en train de lire un article ne le verra même pas ^_^

conclusion:

En quelques scripts bash nous avons pu :
- vérifier l’enregistrement DNS d'un domaine ou d'un sous-domaines.
- Générer un certificat SSL.
- surveiller la date d'expiration d'un ou plusieurs certificat SSL et recevoir un mail en conséquence.

Dans tous cela, nous avons également pris en compte le point de sécurité, en gérant les règles iptables nécessaires à la volée dans les script

Enfin, Nous également ajouté une touche d'automatisation via Gitlab et un playbook ansible plutôt minimaliste.

Cela nous permet de gagner un temps précieux sur nos tâches d'administration tout en conservant un œil (intervention humaine minimal) sur toute la chaîne et avec un minimum de sécurité.

So, what else ?

what else ?