commit b2051813b6e28cbd0d098deb739daaee78676245 Author: yann Date: Fri Dec 26 17:05:22 2025 +0100 first commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..3feccd9 --- /dev/null +++ b/README.md @@ -0,0 +1,171 @@ +# 🔋 Protection Proxmox via Onduleur EATON Ellipse PRO avec NUT + +## ✅ Objectif + +Assurer un **arrêt automatique propre** de toutes les VMs puis du serveur **Proxmox VE** en cas de coupure de courant, en s'appuyant sur l'onduleur **EATON Ellipse PRO USB** et le logiciel **NUT (Network UPS Tools)**. + +--- + +## ⚡ Composants + +* **UPS** : EATON Ellipse PRO 850 VA (USB) +* **Hôte Proxmox VE** (relié physiquement via USB) +* **Logiciel** : `nut`, `nut-client`, `jq` +* **Notifications** : Webhook Discord +* **Scripts personnalisés** : `/etc/nut/upssched-cmd.sh` + +--- + +## 📅 Fonctionnement + +| Événement | Action | +| ------------------------------- | ------------------------------------------------------------ | +| Passage sur batterie (`onbatt`) | Envoie un message Discord avec état complet de l'UPS | +| Retour secteur (`online`) | Message Discord "Retour secteur" | +| Batterie faible (`shutdown`) | Arrêt propre des VMs (via `qm shutdown`) puis du serveur PVE | + +--- + +## 🔧 Installation & configuration + +### 1. Installer les paquets + +```bash +apt update -y && apt install -y nut jq +``` + +### 2. Détection de l'UPS (via USB) + +```bash +nut-scanner -U +``` + +Confirmer que le modèle EATON est bien détecté. + +### 3. Fichiers de configuration NUT + +#### `/etc/nut/ups.conf` + +```ini +[eaton] + driver = usbhid-ups + port = auto + vendorid = 0463 + productid = FFFF + desc = "Onduleur EATON Ellipse PRO" +``` + +#### `/etc/nut/nut.conf` + +```ini +MODE=standalone +``` + +#### `/etc/nut/upsd.users` + +```ini +[monuser] + password = secret + upsmon master +``` + +#### `/etc/nut/upsmon.conf` + +```ini +MONITOR eaton@localhost 1 monuser secret master +NOTIFYCMD /sbin/upssched +``` + +#### `/etc/nut/upssched.conf` + +```ini +CMDSCRIPT /etc/nut/upssched-cmd.sh +PIPEFN /var/run/nut/upssched.pipe +LOCKFN /var/run/nut/upssched.lock + +AT COMMBAD * EXECUTE onbatt +AT COMMOK * EXECUTE online +AT LOWBATT * EXECUTE shutdown +``` + +--- + +## 🔢 Script `/etc/nut/upssched-cmd.sh` + +### Fonctions incluses : + +* Lecture de l'état de l'UPS via `upsc` +* Log local horodaté `/var/log/ups-shutdown.log` +* Notification Webhook Discord (format Markdown) +* Arrêt propre de toutes les VMs via `qm shutdown` +* Attente de l'extinction totale avant `shutdown -h now` + +### Sécurité : + +Le script contient une variable `SIMULATION=false` pour activer le mode production. +Si mise à `true`, aucun arrêt ne sera réellement effectué (mode test). + +### Autorisations + +```bash +chmod +x /etc/nut/upssched-cmd.sh +``` + +--- + +## 🚀 Notifications Discord + +Chaque événement envoie un message via Webhook (texte ou bloc `markdown`). + +Exemple `onbatt` : + +```markdown +🔕 pve est passé sur batterie à 2025-07-29 09:10:22 +``` + +``` +🖥️ Modèle : Ellipse PRO 850 +🔋 Charge batterie : 46 % +⏳ Autonomie estimée : 168 sec +⚡ Charge appliquée : 47 % +🔌 Entrée : 237.0 V → ⚡ Sortie : 234.0 V +🔋 Puissance : 261 VA +💡 Statut UPS : OL +``` + +--- + +## 🛡️ Test manuel sans danger + +```bash +/etc/nut/upssched-cmd.sh onbatt +``` + +Permet de vérifier les logs + Discord sans exécuter d'arrêt. + +--- + +## 🔺 Bascule en production + +```bash +nano /etc/nut/upssched-cmd.sh +# Remplacer : SIMULATION=true --> SIMULATION=false +``` + +Puis redémarrer NUT : + +```bash +systemctl restart nut-server +systemctl restart nut-client +``` + +--- + +## 📄 Log + +Fichier log local : `/var/log/ups-shutdown.log` +Contient tous les événements UPS avec horodatage. + +--- + +## 💼 Auteur : Ssyleric — 2025-07-29 diff --git a/check-ups-runtime.sh b/check-ups-runtime.sh new file mode 100644 index 0000000..fce4496 --- /dev/null +++ b/check-ups-runtime.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +WEBHOOK="https://discord.com/api/webhooks/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +LOGFILE="/var/log/ups-shutdown.log" + +UPS_DATA=$(upsc eaton@localhost 2>/dev/null) +STATUS=$(echo "$UPS_DATA" | grep '^ups.status:' | awk '{print $2}') +RUNTIME=$(echo "$UPS_DATA" | grep '^battery.runtime:' | awk '{print $2}') +BATTERY_CHARGE=$(echo "$UPS_DATA" | grep '^battery.charge:' | awk '{print $2}') +MODEL=$(echo "$UPS_DATA" | grep '^device.model:' | cut -d ':' -f2- | sed 's/^ *//') + +if [[ "$STATUS" == "OB" && "$RUNTIME" -lt 300 ]]; then + MESSAGE="⏱ *$(hostname)* — autonomie critique détectée !\n🔋 Batterie : ${BATTERY_CHARGE}%\n⏳ Autonomie : ${RUNTIME} sec\n🖥️ Modèle : $MODEL" + echo "$(date '+%F %T') ⚠️ Recheck runtime < 300 sec (batt=$BATTERY_CHARGE%, runtime=$RUNTIME sec)" >> "$LOGFILE" + jq -n --arg content "$MESSAGE" '{content: $content}' | \ + curl -s -H "Content-Type: application/json" -X POST -d @- "$WEBHOOK" > /dev/null +fi diff --git a/check-ups.sh b/check-ups.sh new file mode 100644 index 0000000..725331f --- /dev/null +++ b/check-ups.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +WEBHOOK="https://discord.com/api/webhooks/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +UPS_DATA=$(upsc eaton@localhost 2>/dev/null) + +BATTERY_CHARGE=$(echo "$UPS_DATA" | grep '^battery.charge:' | awk '{print $2}') +RUNTIME=$(echo "$UPS_DATA" | grep '^battery.runtime:' | awk '{print $2}') +LOAD=$(echo "$UPS_DATA" | grep '^ups.load:' | awk '{print $2}') +STATUS=$(echo "$UPS_DATA" | grep '^ups.status:' | awk '{print $2}') +INPUT_VOLT=$(echo "$UPS_DATA" | grep '^input.voltage:' | awk '{print $2}') +OUTPUT_VOLT=$(echo "$UPS_DATA" | grep '^output.voltage:' | awk '{print $2}') +POWER=$(echo "$UPS_DATA" | grep '^ups.power:' | awk '{print $2}') +MODEL=$(echo "$UPS_DATA" | grep '^device.model:' | cut -d ':' -f2- | sed 's/^ *//') + +TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S') + +MESSAGE="🕓 *État UPS à ${TIMESTAMP}* +🖥️ Modèle : $MODEL +🔋 Charge batterie : ${BATTERY_CHARGE} % +⏳ Autonomie estimée : ${RUNTIME} sec +⚡ Charge appliquée : ${LOAD} % +🔌 Entrée : ${INPUT_VOLT} V → ⚡ Sortie : ${OUTPUT_VOLT} V +🔋 Puissance : ${POWER} VA +💡 Statut UPS : $STATUS" + +jq -n --arg content "$MESSAGE" '{content: $content}' | \ + curl -s -H "Content-Type: application/json" -X POST -d @- "$WEBHOOK" > /dev/null diff --git a/upssched-cmd.sh b/upssched-cmd.sh new file mode 100644 index 0000000..ced528a --- /dev/null +++ b/upssched-cmd.sh @@ -0,0 +1,107 @@ +#!/bin/bash + +LOGFILE="/var/log/ups-shutdown.log" +WEBHOOK="https://discord.com/api/webhooks/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +SIMULATION=false # ✅ mode production (⚠️ coupe vraiment les VMs et le serveur) + +send_discord() { + MESSAGE="$1" + curl -s -H "Content-Type: application/json" -X POST -d "{\"content\": \"$MESSAGE\"}" "$WEBHOOK" > /dev/null +} + +send_log_discord() { + LOG_CONTENT=$(tail -n 50 "$LOGFILE" | head -c 1900 | jq -Rs .) + jq -n --arg text "🔻 \`$(hostname)\` extinction imminente — $(date '+%F %T')\n\n" \ + --arg log "$LOG_CONTENT" \ + '{content: ($text + "```" + $log + "```")}' | + curl -s -H "Content-Type: application/json" -X POST -d @- "$WEBHOOK" > /dev/null +} + +send_discord_onbatt() { + UPS_DATA=$(upsc eaton@localhost 2>/dev/null) + + BATTERY_CHARGE=$(echo "$UPS_DATA" | grep '^battery.charge:' | awk '{print $2}') + RUNTIME=$(echo "$UPS_DATA" | grep '^battery.runtime:' | awk '{print $2}') + LOAD=$(echo "$UPS_DATA" | grep '^ups.load:' | awk '{print $2}') + STATUS=$(echo "$UPS_DATA" | grep '^ups.status:' | awk '{print $2}') + INPUT_VOLT=$(echo "$UPS_DATA" | grep '^input.voltage:' | awk '{print $2}') + OUTPUT_VOLT=$(echo "$UPS_DATA" | grep '^output.voltage:' | awk '{print $2}') + POWER=$(echo "$UPS_DATA" | grep '^ups.power:' | awk '{print $2}') + MODEL=$(echo "$UPS_DATA" | grep '^device.model:' | cut -d ':' -f2- | sed 's/^ *//') + + LOG_FORMAT=$(cat < /dev/null +} + +check_runtime_warning() { + UPS_DATA=$(upsc eaton@localhost 2>/dev/null) + RUNTIME=$(echo "$UPS_DATA" | grep '^battery.runtime:' | awk '{print $2}') + BATTERY_CHARGE=$(echo "$UPS_DATA" | grep '^battery.charge:' | awk '{print $2}') + MODEL=$(echo "$UPS_DATA" | grep '^device.model:' | cut -d ':' -f2- | sed 's/^ *//') + + if [[ "$RUNTIME" -lt 300 ]]; then + MESSAGE="⚠️ *$(hostname)* — autonomie UPS **trop faible** malgré batterie à ${BATTERY_CHARGE}%\nModèle: $MODEL\n⏳ Autonomie estimée : ${RUNTIME} sec" + jq -n --arg content "$MESSAGE" '{content: $content}' | + curl -s -H "Content-Type: application/json" -X POST -d @- "$WEBHOOK" > /dev/null + echo "$(date '+%F %T') 🚨 Alerte : autonomie faible avec charge ${BATTERY_CHARGE}% (runtime=${RUNTIME})" >> "$LOGFILE" + fi +} + +case $1 in + onbatt) + logger "[NUT] ⚠️ Passage sur batterie détecté" + echo "$(date '+%F %T') ⚠️ UPS on battery" >> "$LOGFILE" + send_discord_onbatt + check_runtime_warning + ;; + + online) + logger "[NUT] 🔌 Retour à l'alimentation secteur" + echo "$(date '+%F %T') 🔌 Retour secteur" >> "$LOGFILE" + send_discord "🔌 **$(hostname)** est revenu sur **secteur** à **$(date '+%Y-%m-%d %H:%M:%S')** ⚡" + ;; + + shutdown) + logger "[NUT] 🛑 Batterie faible — extinction imminente (simulation=$SIMULATION)" + echo "$(date '+%F %T') 🛑 Batterie faible — arrêt des VMs + Proxmox (simulation=$SIMULATION)" >> "$LOGFILE" + send_discord "🛑 **$(hostname)** — batterie faible détectée à **$(date '+%Y-%m-%d %H:%M:%S')** ⚡\nArrêt des VMs en cours (simulation=$SIMULATION)..." + + for vmid in $(qm list | awk 'NR>1 {print $1}'); do + echo "$(date '+%F %T') → Arrêt VM $vmid" >> "$LOGFILE" + if [[ "$SIMULATION" != "true" ]]; then + qm shutdown $vmid --timeout 300 + fi + done + + echo "$(date '+%F %T') ⏳ Attente extinction des VMs…" >> "$LOGFILE" + if [[ "$SIMULATION" != "true" ]]; then + while qm list | awk 'NR>1 {print $2}' | grep -q running; do + sleep 5 + done + fi + + echo "$(date '+%F %T') ✅ VMs arrêtées" >> "$LOGFILE" + echo "$(date '+%F %T') 🔻 Shutdown final (simulation=$SIMULATION)" >> "$LOGFILE" + send_log_discord + + if [[ "$SIMULATION" != "true" ]]; then + shutdown -h now + fi + ;; +esac