Mise à jour des paquets, synchro NTP et notifications par e-mail HTML sur Ubuntu 24.04


Gérer manuellement plusieurs serveurs Linux devient vite fastidieux et source d’erreurs : oubli d’une mise à jour, décalage horaire non détecté, configuration NTP hétérogène… Ansible répond à ce problème en permettant d’automatiser l’administration système de façon reproductible, sans agent à installer sur les machines cibles.

Nous allons mettre en place un serveur Ansible sous Ubuntu 24.04 LTS, configurer un inventaire de serveurs, et déployer un playbook complet qui réalise en une seule exécution :

  • La configuration du fuseau horaire (Europe/Paris) sur tous les serveurs
  • Le déploiement de Chrony et la synchronisation NTP sur un serveur interne
  • La mise à jour complète des paquets (apt upgrade)
  • L’envoi d’un e-mail récapitulatif HTML avec le statut de chaque serveur

Un point important de cette configuration : le port SSH par défaut (22) a été remplacé par un port personnalisé sur tous les serveurs, ce que nous intégrerons dans la configuration Ansible.


Pour reproduire cette configuration, vous aurez besoin de :

  • Un serveur Ansible sous Ubuntu 24.04 LTS (le « contrôleur »)
  • Plusieurs serveurs Linux Ubuntu 24.04 à gérer (les « nœuds »)
  • Un serveur NTP interne accessible sur le réseau (port UDP 123)
  • Un serveur SMTP interne pour l’envoi des e-mails (Postfix ou équivalent)
  • Un utilisateur commun avec accès sudo sur tous les serveurs

Authentification SSH par clé

Ansible utilise SSH pour se connecter aux nœuds. Il est impératif de configurer une authentification par clé publique/privée pour éviter de saisir des mots de passe à chaque exécution.

Sur le serveur Ansible, générez une paire de clés SSH si ce n’est pas déjà fait :

ssh-keygen -t rsa -b 4096 -f ~/.ssh/id_rsa

Puis déployez la clé publique sur chaque nœud. Si votre port SSH est personnalisé (ici le port 12345 sera utilisé comme exemple dans cet article), spécifiez-le explicitement :

ssh-copy-id -p 12345 -i ~/.ssh/id_rsa.pub utilisateur@192.168.1.10

Vérifiez que la connexion fonctionne sans mot de passe :

ssh -p 12345 utilisateur@192.168.1.10 'echo Connexion OK'

Installation d’Ansible

Sur Ubuntu 24.04, Ansible est disponible dans les dépôts officiels :

sudo apt update
sudo apt install ansible -y

Vérifiez l’installation :

ansible --version

Vous devriez obtenir une sortie similaire à :

ansible [core 2.16.3]
  config file = /etc/ansible/ansible.cfg
  python version = 3.12.3
  jinja version = 3.1.2

Installez également la collection community.general nécessaire pour la gestion du fuseau horaire :

ansible-galaxy collection install community.general

Configuration de l’inventaire

L’inventaire Ansible liste les machines à gérer. Éditez le fichier /etc/ansible/hosts :

sudo nano /etc/ansible/hosts

Voici un exemple d’inventaire avec plusieurs serveurs répartis sur différents sous-réseaux, en utilisant un port SSH personnalisé :

[serveurs]
srv-ansible   ansible_host=192.168.1.5   ansible_port=12345 ansible_user=admin ansible_ssh_private_key_file=/home/admin/.ssh/id_rsa ansible_python_interpreter=/usr/bin/python3
srv-ntp       ansible_host=192.168.1.10  ansible_port=12345 ansible_user=admin ansible_ssh_private_key_file=/home/admin/.ssh/id_rsa ansible_python_interpreter=/usr/bin/python3
srv-web       ansible_host=192.168.2.20  ansible_port=12345 ansible_user=admin ansible_ssh_private_key_file=/home/admin/.ssh/id_rsa ansible_python_interpreter=/usr/bin/python3
srv-mail      ansible_host=192.168.2.35  ansible_port=12345 ansible_user=admin ansible_ssh_private_key_file=/home/admin/.ssh/id_rsa ansible_python_interpreter=/usr/bin/python3
srv-backup    ansible_host=192.168.3.50  ansible_port=12345 ansible_user=admin ansible_ssh_private_key_file=/home/admin/.ssh/id_rsa ansible_python_interpreter=/usr/bin/python3
Paramètre Description
ansible_host Adresse IP ou nom DNS du serveur cible
ansible_port Port SSH (ici 12345 au lieu du port 22 par défaut)
ansible_user Utilisateur SSH avec droits sudo
ansible_ssh_private_key_file Chemin vers la clé privée SSH
ansible_python_interpreter Force l’utilisation de Python 3

Testez la connectivité vers tous les serveurs :

ansible serveurs -m ping

Une réponse pong pour chaque serveur confirme que tout est en ordre.


Le playbook de maintenance

Créez le fichier du playbook :

sudo nano /etc/ansible/update_servers.yml

Structure générale

Le playbook commence par déclarer ses métadonnées et le groupe de serveurs cibles :

---
- name: Configuration NTP, fuseau horaire et mise à jour des paquets
  hosts: serveurs
  become: true
  become_user: root

  tasks:

Le paramètre become: true permet l’escalade de privilèges via sudo, nécessaire pour les opérations d’administration système.

Tâche 1 : Configuration du fuseau horaire

    - name: Définir le fuseau horaire sur Europe/Paris
      community.general.timezone:
        name: Europe/Paris
      register: timezone_set
      when: inventory_hostname != 'srv-ntp'

Le module community.general.timezone configure le fuseau horaire de façon idempotente : il ne modifiera le système que si nécessaire. Le résultat est stocké dans timezone_set pour être utilisé dans le rapport final.

UPDATE : Je rajoute le parametre when: inventory_hostname != 'srv-ntp' par ce que mon serveur ntp est lui meme un chrony et qu’il fait partie de la liste des serveur a updater donc si j’ecase la config du serveur par la config des clients ca va pas le faire

Tâche 2 : Installation de Chrony

    - name: S'assurer que Chrony est installé
      ansible.builtin.apt:
        name: chrony
        state: present
      register: chrony_installed
      when: inventory_hostname != 'srv-ntp'

Le paramètre state: present garantit que le paquet est installé sans le réinstaller s’il l’est déjà. C’est le principe d’idempotence d’Ansible.

Tâche 3 : Déploiement de la configuration NTP

C’est une tâche clé : au lieu d’ajouter simplement une ligne dans le fichier de configuration existant (ce qui laisserait les serveurs publics Ubuntu actifs), on remplace intégralement le fichier chrony.conf par une configuration épurée pointant uniquement vers notre serveur NTP interne :

    - name: Déployer la configuration chrony.conf
      ansible.builtin.copy:
        dest: /etc/chrony/chrony.conf
        content: |
          server 192.168.1.10 iburst
          keyfile /etc/chrony/chrony.keys
          driftfile /var/lib/chrony/chrony.drift
          ntsdumpdir /var/lib/chrony
          logdir /var/log/chrony
          maxupdateskew 100.0
          rtcsync
          makestep 1 3
          leapsectz right/UTC          
        backup: yes
      register: chrony_config_changed
      when: inventory_hostname != 'srv-ntp'

Le paramètre backup: yes crée automatiquement une sauvegarde de l’ancien fichier avant de l’écraser. Le paramètre iburst accélère la synchronisation initiale en envoyant plusieurs requêtes rapprochées au démarrage.

Pourquoi remplacer entièrement le fichier ? Par défaut, Ubuntu 24.04 configure Chrony avec plusieurs pools de serveurs publics. Si on se contente d’ajouter notre serveur interne en fin de fichier, Chrony continuera à utiliser les serveurs publics en priorité. En remplaçant le fichier, on garantit que seul notre NTP interne est utilisé.

Tâche 4 : Redémarrage de Chrony

    - name: Redémarrer le service Chrony
      ansible.builtin.systemd:
        name: chronyd
        state: restarted
        enabled: yes
      register: chrony_service_restarted
      when: inventory_hostname != 'srv-ntp'

Le paramètre enabled: yes s’assure également que Chrony démarrera automatiquement au prochain reboot du serveur.

Tâche 5 : Vérification de la synchronisation NTP

    - name: Vérifier la synchronisation NTP
      ansible.builtin.command:
        cmd: chronyc tracking
      register: ntp_status
      changed_when: false

Le paramètre changed_when: false indique à Ansible que cette tâche ne modifie jamais l’état du système. Sans ce paramètre, Ansible afficherait un CHANGED à chaque exécution, ce qui fausserait les statistiques.

Tâches 6 et 7 : Mise à jour des paquets

    - name: Mettre à jour la liste des paquets
      ansible.builtin.apt:
        update_cache: yes
        cache_valid_time: 3600
      register: apt_update_result

    - name: Effectuer une mise à jour complète des paquets
      ansible.builtin.apt:
        upgrade: full
        autoremove: yes
        autoclean: yes
      register: apt_upgrade_result

Le paramètre cache_valid_time: 3600 évite de re-télécharger les listes de paquets si elles ont déjà été mises à jour il y a moins d’une heure. Le paramètre upgrade: full est l’équivalent de apt full-upgrade qui gère également les changements de dépendances. Les options autoremove et autoclean nettoient les paquets orphelins et le cache APT.

Tâche 8 : Détection du besoin de redémarrage

    - name: Vérifier si un redémarrage est nécessaire
      ansible.builtin.stat:
        path: /var/run/reboot-required
      register: reboot_required

Après une mise à jour noyau ou de bibliothèques critiques, Ubuntu crée le fichier /var/run/reboot-required. Ce module vérifie son existence pour le signaler dans le rapport. Ansible ne redémarre pas le serveur automatiquement — c’est une décision volontaire pour éviter toute interruption de service non planifiée.

Tâche 9 : Construction du rapport HTML

C’est la tâche la plus complexe. Elle utilise le moteur de templates Jinja2 intégré à Ansible pour construire dynamiquement un e-mail HTML avec le statut de chaque serveur :

    - name: Construire le message récapitulatif global
      ansible.builtin.set_fact:
        recap_message: |
          <html>
          <body style="font-family: Arial, sans-serif; background-color: #f4f4f4; padding: 20px;">
          <div style="max-width: 800px; margin: auto; background: white; border-radius: 8px; padding: 20px;">
            <h2 style="color: #2c3e50; border-bottom: 2px solid #3498db; padding-bottom: 10px;">
              ??? Rapport de mise à jour Ansible
            </h2>
            <p style="color: #7f8c8d;">Exécuté le {{ ansible_date_time.date }} à {{ ansible_date_time.time }}</p>
          {% for host in ansible_play_hosts %}
            <div style="margin-top: 20px; border: 1px solid #ddd; border-radius: 6px; overflow: hidden;">
              <div style="background-color: #3498db; color: white; padding: 10px 15px;">
                <h3 style="margin: 0;">{{ host | upper }}</h3>
              </div>
              <table style="width: 100%; border-collapse: collapse;">
                <tr style="background-color: #f9f9f9;">
                  <td style="padding: 8px 15px; border-bottom: 1px solid #eee; width: 60%;">Fuseau horaire (Europe/Paris)</td>
                  <td style="padding: 8px 15px; border-bottom: 1px solid #eee;">
                    {% if hostvars[host].timezone_set.changed %}
                      <span style="color: #e67e22;">?? MODIFIÉ</span>
                    {% else %}
                      <span style="color: #27ae60;">? OK</span>
                    {% endif %}
                  </td>
                </tr>
                <tr>
                  <td style="padding: 8px 15px; border-bottom: 1px solid #eee;">Chrony</td>
                  <td style="padding: 8px 15px; border-bottom: 1px solid #eee;">
                    {% if hostvars[host].chrony_installed.changed %}
                      <span style="color: #e67e22;">?? INSTALLÉ</span>
                    {% else %}
                      <span style="color: #27ae60;">? PRÉSENT</span>
                    {% endif %}
                  </td>
                </tr>
                <tr style="background-color: #f9f9f9;">
                  <td style="padding: 8px 15px; border-bottom: 1px solid #eee;">Configuration NTP</td>
                  <td style="padding: 8px 15px; border-bottom: 1px solid #eee;">
                    {% if hostvars[host].chrony_config_changed.changed %}
                      <span style="color: #e67e22;">?? MODIFIÉ</span>
                    {% else %}
                      <span style="color: #27ae60;">? OK</span>
                    {% endif %}
                  </td>
                </tr>
                <tr>
                  <td style="padding: 8px 15px; border-bottom: 1px solid #eee;">Mise à jour des paquets</td>
                  <td style="padding: 8px 15px; border-bottom: 1px solid #eee;">
                    {% if hostvars[host].apt_upgrade_result.changed %}
                      <span style="color: #3498db;">?? EFFECTUÉE</span>
                    {% else %}
                      <span style="color: #27ae60;">? À JOUR</span>
                    {% endif %}
                  </td>
                </tr>
                <tr style="background-color: #f9f9f9;">
                  <td style="padding: 8px 15px;">Redémarrage nécessaire</td>
                  <td style="padding: 8px 15px;">
                    {% if hostvars[host].reboot_required.stat.exists %}
                      <span style="color: #e74c3c;">?? OUI</span>
                    {% else %}
                      <span style="color: #27ae60;">? NON</span>
                    {% endif %}
                  </td>
                </tr>
              </table>
            </div>
          {% endfor %}
          </div>
          </body>
          </html>          
      run_once: true

Points importants de cette tâche :

  • run_once: true : la tâche ne s’exécute qu’une seule fois mais accède aux données de tous les serveurs via hostvars
  • ansible_play_hosts : variable magique Ansible contenant la liste de tous les hôtes du play en cours
  • hostvars[host] : permet d’accéder aux variables d’un hôte spécifique depuis n’importe quelle tâche
  • ansible_date_time : variable automatique Ansible contenant la date et l’heure d’exécution

Tâche 10 : Envoi de l’e-mail récapitulatif

    - name: Envoyer un e-mail récapitulatif unique
      ansible.builtin.mail:
        host: smtp.mondomaine.local
        port: 25
        to: "admin@mondomaine.local"
        subject: "Rapport de mise à jour Ansible - {{ ansible_date_time.date }}"
        body: "{{ recap_message }}"
        from: "ansible@srv-ansible.mondomaine.local"
        secure: never
        subtype: html
      run_once: true
      delegate_to: localhost

Deux paramètres méritent une explication :

  • secure: never : désactive le TLS pour l’envoi SMTP. Nécessaire si votre serveur SMTP interne n’a pas de certificat TLS configuré. Ne jamais utiliser cette option vers un serveur SMTP externe.
  • delegate_to: localhost : délègue l’envoi de l’e-mail au serveur Ansible lui-même plutôt qu’à l’un des nœuds gérés. C’est logique puisque c’est le contrôleur qui doit envoyer le rapport.

Planification avec Cron

Pour automatiser l’exécution hebdomadaire, ajoutez une tâche cron sur le serveur Ansible. L’exemple ci-dessous planifie l’exécution chaque lundi à 3h00 :

sudo crontab -e

Ajoutez la ligne :

0 3 * * 1 ansible-playbook /etc/ansible/update_servers.yml >> /var/log/ansible-update.log 2>&1

La redirection >> /var/log/ansible-update.log 2>&1 sauvegarde la sortie complète (stdout et stderr) dans un fichier de log pour pouvoir diagnostiquer d’éventuels problèmes.


Résultat : le rapport par e-mail

À chaque exécution du playbook, vous recevez un e-mail HTML structuré avec un tableau par serveur :

Indicateur Signification
? OK / PRÉSENT / À JOUR La vérification est passée, aucune modification nécessaire
?? MODIFIÉ / INSTALLÉ Une modification a été apportée lors de cette exécution
?? EFFECTUÉE Des paquets ont été mis à jour
?? OUI (redémarrage) Un redémarrage est requis suite aux mises à jour

Le sujet de l’e-mail inclut automatiquement la date d’exécution, ce qui permet de distinguer facilement les rapports dans votre boîte mail.


Dépannage fréquent

Le NTP ne se synchronise pas

Si chronyc sources affiche ? devant votre serveur NTP interne, vérifiez que le port UDP 123 est accessible :

nc -uz 192.168.1.10 123 && echo 'Port 123 OK' || echo 'Port 123 BLOQUÉ'

Si vous utilisez un serveur Windows comme référence NTP, vérifiez que le service est bien actif :

w32tm /query /status

L’e-mail n’arrive pas

Testez l’envoi SMTP depuis le serveur Ansible :

echo 'Test Ansible' | mail -s 'Test' admin@mondomaine.local

Vérifiez les logs du serveur SMTP et assurez-vous que le port 25 est accessible depuis le serveur Ansible.

Erreur StartTLS

Si vous obtenez l’erreur StartTLS is not offered on server, c’est que votre serveur SMTP interne ne supporte pas TLS. Vérifiez que vous avez bien secure: never dans la tâche d’envoi d’e-mail du playbook.


Voila ! Nous avons mis en place une solution complète d’automatisation avec Ansible qui gère en une seule commande la configuration NTP, le fuseau horaire, les mises à jour de paquets et la notification par e-mail sur l’ensemble du parc de serveurs Linux.

Quelques points clés à retenir :

  • L’idempotence : chaque tâche Ansible vérifie l’état actuel avant d’agir. Vous pouvez exécuter le playbook autant de fois que vous voulez sans risque.
  • Le remplacement complet de chrony.conf est essentiel pour garantir que seul votre NTP interne est utilisé.
  • Le rapport HTML avec Jinja2 et hostvars permet de centraliser les informations de tous les serveurs dans un seul e-mail lisible.
  • Le port SSH personnalisé est une mesure de sécurité simple qui réduit le bruit des scans automatiques, et s’intègre facilement dans l’inventaire Ansible.

Enjoy !

Annexe : Playbook complet

---
- name: Configuration NTP, fuseau horaire et mise à jour des paquets
  hosts: serveurs
  become: true
  become_user: root

  tasks:
    - name: Définir le fuseau horaire sur Europe/Paris
      community.general.timezone:
        name: Europe/Paris
      register: timezone_set

    - name: S'assurer que Chrony est installé
      ansible.builtin.apt:
        name: chrony
        state: present
      register: chrony_installed

    - name: Déployer la configuration chrony.conf
      ansible.builtin.copy:
        dest: /etc/chrony/chrony.conf
        content: |
          server 192.168.1.10 iburst
          keyfile /etc/chrony/chrony.keys
          driftfile /var/lib/chrony/chrony.drift
          ntsdumpdir /var/lib/chrony
          logdir /var/log/chrony
          maxupdateskew 100.0
          rtcsync
          makestep 1 3
          leapsectz right/UTC          
        backup: yes
      register: chrony_config_changed

    - name: Redémarrer le service Chrony
      ansible.builtin.systemd:
        name: chronyd
        state: restarted
        enabled: yes
      register: chrony_service_restarted

    - name: Vérifier la synchronisation NTP
      ansible.builtin.command:
        cmd: chronyc tracking
      register: ntp_status
      changed_when: false

    - name: Mettre à jour la liste des paquets
      ansible.builtin.apt:
        update_cache: yes
        cache_valid_time: 3600
      register: apt_update_result

    - name: Effectuer une mise à jour complète des paquets
      ansible.builtin.apt:
        upgrade: full
        autoremove: yes
        autoclean: yes
      register: apt_upgrade_result

    - name: Vérifier si un redémarrage est nécessaire
      ansible.builtin.stat:
        path: /var/run/reboot-required
      register: reboot_required

    - name: Construire le message récapitulatif global
      ansible.builtin.set_fact:
        recap_message: |
          [... voir section Tâche 9 pour le contenu HTML complet ...]          
      run_once: true

    - name: Envoyer un e-mail récapitulatif unique
      ansible.builtin.mail:
        host: smtp.mondomaine.local
        port: 25
        to: "admin@mondomaine.local"
        subject: "Rapport Ansible - {{ ansible_date_time.date }}"
        body: "{{ recap_message }}"
        from: "ansible@srv-ansible.mondomaine.local"
        secure: never
        subtype: html
      run_once: true
      delegate_to: localhost