Post

Mises à jour automatiques de serveurs Debian avec notification ntfy

Un script, un timer systemd et ntfy pour mettre à jour automatiquement des serveurs Debian locaux et des VPS, sans fenêtre bloquante needrestart ni redémarrage automatique.

Mises à jour automatiques de serveurs Debian avec notification ntfy

Objectif

Mettre à jour automatiquement un parc de serveurs Debian (locaux et VPS distants) et recevoir une notification ntfy uniquement quand des mises à jour ont été effectuées, ou en cas d’échec.

Contraintes retenues :

  • aucun redémarrage automatique ;
  • aucune notification quotidienne « rien à faire » ;
  • chaque serveur se met à jour seul (pas de dépendance à une machine centrale) ;
  • l’origine de la notification doit être identifiable (nom d’hôte et IP).

Le problème : la fenêtre needrestart

Lors d’une mise à jour qui change le noyau, needrestart ouvre une fenêtre « Pending kernel upgrade » avec un seul bouton OK. Il faut appuyer sur Entrée dans le terminal, ce qui bloque toute mise à jour automatisée.

Solutions possibles :

1
2
# à la volée
sudo NEEDRESTART_MODE=a apt upgrade -y

ou de façon permanente dans /etc/needrestart/needrestart.conf :

1
2
$nrconf{restart} = 'a';       # redémarre automatiquement les services concernés
$nrconf{kernelhints} = -1;    # message texte, sans boîte de dialogue

Le script ci-dessous exporte DEBIAN_FRONTEND=noninteractive et NEEDRESTART_MODE=a, ce qui supprime toute interaction.

Un nouveau noyau n’est actif qu’après un reboot. Le script ne redémarre rien, mais la notification signale quand un redémarrage est nécessaire.

Principe

Trois éléments identiques sur chaque serveur :

Fichier Rôle
/usr/local/sbin/maj-auto script de mise à jour et de notification
/etc/maj-auto.env URL ntfy (topic inclus), token, email
maj-auto.service + maj-auto.timer exécution quotidienne par systemd

Configuration ntfy : ntfy.env

1
2
3
NTFY_URL="https://ntfy.exemple.org/mon_topic"
NTFY_TOKEN="tk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
NTFY_EMAIL="ntfy@exemple.org"

Le fichier est installé en 0600 (propriétaire root) sur chaque serveur, car il contient le token.

Le script maj-auto

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
#!/bin/bash
set -uo pipefail
source /etc/maj-auto.env

HOST=$(hostname -s)
IP=$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src"){print $(i+1); exit}}')
[ -z "$IP" ] && IP=$(ip -6 route get 2606:4700:4700::1111 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src"){print $(i+1); exit}}')
ID="$HOST (${IP:-IP inconnue})"
TS=$(tailscale ip -4 2>/dev/null | head -1)
[ -n "$TS" ] && ID+=" [ts $TS]"

export DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a NEEDRESTART_SUSPEND=1

notify() {  # titre, priorité, tags, message, [email=1]
  local h=(-H "Title: $1" -H "Priority: $2" -H "Tags: $3")
  [ -n "${NTFY_TOKEN:-}" ] && h+=(-H "Authorization: Bearer $NTFY_TOKEN")
  [ "${5:-0}" = 1 ] && [ -n "${NTFY_EMAIL:-}" ] && h+=(-H "Email: $NTFY_EMAIL")
  curl -fsS -m 20 "${h[@]}" -d "$4" "$NTFY_URL" >/dev/null
}

MODE=${1:-}

if [ "$MODE" = "--test-echec" ]; then
  notify "$ID : TEST échec mise à jour" high warning \
    "Test d'échec (aucune mise à jour réelle)"$'\n'"Voir : journalctl -u maj-auto" 1
  exit 0
fi

if [ "$MODE" = "--test" ]; then
  LISTE="paquet-exemple-1 paquet-exemple-2 linux-image-amd64 "
  PREFIXE="TEST : "
else
  PREFIXE=""
  APT=(apt-get -y -q -o DPkg::Lock::Timeout=300
       -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold)

  if ! "${APT[@]}" update; then
    notify "$ID : échec apt update" high warning \
      "apt-get update a échoué. Voir : journalctl -u maj-auto" 1
    exit 1
  fi

  LISTE=$(apt list --upgradable 2>/dev/null | tail -n +2 | cut -d/ -f1 | tr '\n' ' ')
  [ -z "${LISTE// }" ] && { echo "Rien à mettre à jour"; exit 0; }

  if ! "${APT[@]}" full-upgrade; then
    notify "$ID : échec mise à jour" high warning \
      "Paquets prévus : $LISTE"$'\n'"Voir : journalctl -u maj-auto" 1
    exit 1
  fi
  "${APT[@]}" autoremove --purge
fi

MSG="${PREFIXE}$(wc -w <<<"$LISTE") paquet(s) mis à jour : $LISTE"
TAGS="package,white_check_mark"
if [ -f /var/run/reboot-required ]; then
  MSG+=$'\n'"⚠ Redémarrage nécessaire (noyau actif : $(uname -r))"
fi
notify "$ID : ${PREFIXE}mises à jour effectuées" default "$TAGS" "$MSG"

Points notables :

  • --force-confold et --force-confdef évitent les questions sur les fichiers de configuration modifiés ;
  • DPkg::Lock::Timeout=300 attend la libération du verrou dpkg si un autre processus apt tourne ;
  • le script s’arrête silencieusement s’il n’y a rien à mettre à jour ;
  • l’IP est celle de la route par défaut (IPv4, sinon IPv6 pour les VPS IPv6 only), complétée par l’IP Tailscale si elle existe ;
  • l’email ntfy n’est joint qu’aux échecs.

Service et timer systemd

1
2
3
4
5
6
7
8
[Unit]
Description=Mise à jour automatique + notification ntfy
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/sbin/maj-auto
1
2
3
4
5
6
7
8
9
10
[Unit]
Description=Mise à jour automatique quotidienne

[Timer]
OnCalendar=*-*-* 04:00
RandomizedDelaySec=30m
Persistent=true

[Install]
WantedBy=timers.target

RandomizedDelaySec décale les serveurs entre eux, et Persistent=true rattrape une exécution manquée si la machine était éteinte à l’heure prévue.

Installation sur un serveur

1
2
3
4
5
6
7
8
9
#!/bin/bash
set -e
d=$1
install -m750 "$d/maj-auto" /usr/local/sbin/maj-auto
install -m600 "$d/ntfy.env" /etc/maj-auto.env
install -m644 "$d/maj-auto.service" "$d/maj-auto.timer" /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now maj-auto.timer
systemctl list-timers maj-auto.timer --no-pager

Déploiement sur tous les serveurs

Les fichiers sont empaquetés (tar + base64) et envoyés dans une seule commande SSH. Ici, la connexion passe par un script maison (sshm <alias> exe 'commande'), mais un simple ssh "$a" "commande" convient aussi.

1
2
3
4
5
6
7
8
9
10
11
#!/bin/bash
# usage : ./deploy-maj-auto.sh alias1 alias2 ...
set -euo pipefail
cd "$(dirname "$0")"
PAYLOAD=$(tar czf - maj-auto maj-auto.service maj-auto.timer ntfy.env install.sh | base64 -w0)

for a in "$@"; do
  echo ">>> $a"
  sshm "$a" exe "d=\$(mktemp -d) && echo '$PAYLOAD' | base64 -d | tar xz -C \$d \
    && S=\$([ \$(id -u) = 0 ] || echo sudo) && \$S bash \$d/install.sh \$d; rm -rf \$d"
done

Le déploiement est répétable : relancer la commande réinstalle simplement les fichiers, ce qui sert aussi à propager une modification du script.

Le dossier de déploiement contient le token ntfy : gardez-le avec des droits restreints (chmod 700). Le déploiement suppose sudo sans mot de passe sur les serveurs non root.

Tests

Sur la machine
Deux options intégrées envoient une notification complète sans exécuter apt :

1
2
sudo /usr/local/sbin/maj-auto --test          # simulation « mises à jour effectuées »
sudo /usr/local/sbin/maj-auto --test-echec    # simulation d'échec, avec email

Vérification du timer et du journal du vrai passage :

1
2
3
systemctl list-timers maj-auto.timer
sudo systemctl start maj-auto.service
sudo journalctl -u maj-auto -n 30 --no-pager

/etc/maj-auto.env est en 0600 : un test avec source /etc/maj-auto.env doit se faire sous sudo, sinon « Permission non accordée ». Le timer, lui, tourne en root et n’est pas concerné.

Depuis le poste ayant sshm

1
2
3
4
# simulation « mises à jour effectuées »
sshm alias exe 'sudo /usr/local/sbin/maj-auto --test'
# simulation d'échec, avec email
sshm alias exe 'sudo /usr/local/sbin/maj-auto --test-echec'

Contenu des notifications

Mises à jour effectuées (priorité normale, icônes 📦 ✅) :

1
2
3
Titre : srv-web (192.168.0.12) [ts 100.64.0.5] : mises à jour effectuées
Corps : 5 paquet(s) mis à jour : libc6 libssl3 openssl curl linux-image-amd64
        ⚠ Redémarrage nécessaire (noyau actif : 6.12.43+deb13-amd64)

La ligne « Redémarrage nécessaire » n’apparaît que si /var/run/reboot-required existe.

Échec de apt update ou de la mise à jour : priorité haute, icône ⚠️, email envoyé à NTFY_EMAIL, et renvoi vers journalctl -u maj-auto.

Si tout est déjà à jour, rien n’est envoyé.

Points d’attention

  • Redémarrage : à faire manuellement, au moment voulu, quand la notification le signale.
  • Proxmox / cluster : full-upgrade convient aux hôtes PVE, mais mieux vaut éviter de mettre à jour tous les nœuds d’un cluster simultanément ; le délai aléatoire du timer les décale déjà.
  • Longueur du message : ntfy accepte environ 4 Ko par message. Sur une première exécution avec de nombreux paquets, la liste peut être tronquée côté script si nécessaire.
  • Alternative : unattended-upgrades fait nativement des mises à jour de sécurité seules, mais ne sait pas notifier via ntfy ; il faudrait le coupler à un script de ce type.
Cet article est sous licence CC BY 4.0 par l'auteur.