458 lines
13 KiB
Bash
Executable File
458 lines
13 KiB
Bash
Executable File
#!/bin/bash
|
|
# ==============================================================================
|
|
# Manutenzione settimanale Raspberry Pi
|
|
# Aggiorna OS, Pi-hole, EEPROM; verifica Docker/pip; report + Telegram.
|
|
# Conclude sempre con reboot host (salvo --no-reboot o REBOOT_ON_SUCCESS=false).
|
|
# Eseguire come root (cron root).
|
|
# ==============================================================================
|
|
|
|
set -uo pipefail
|
|
|
|
# PATH completo: il crontab root non eredita /usr/sbin (serve per shutdown, ecc.)
|
|
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
|
|
|
CONF_FILE="/etc/weekly-maintenance.conf"
|
|
LOG_FILE="/var/log/weekly-maintenance.log"
|
|
REPORT_FILE="/var/log/weekly-maintenance-report.txt"
|
|
TOKEN_FILE="/etc/telegram_dpc_bot_token"
|
|
CHAT_ID="64463169"
|
|
PIHOLE_BIN="/usr/local/bin/pihole"
|
|
REBOOT_DELAY_MIN=2
|
|
WATCHTOWER_IMAGE="nickfedor/watchtower:latest"
|
|
WATCHTOWER_TIMEOUT=900
|
|
|
|
HOST_LABEL="$(hostname -s)"
|
|
REBOOT_ON_SUCCESS=true
|
|
TELEGRAM_ENABLED=true
|
|
CHECK_PIP3=false
|
|
DOCKER_IGNORE_IMAGES=()
|
|
DOCKER_PINNED_IMAGES=()
|
|
|
|
declare -a ERRORS=()
|
|
declare -a WARNINGS=()
|
|
declare -a NOTES=()
|
|
REBOOT_RECOMMENDED=false
|
|
APT_CHANGED=false
|
|
DOCKER_UPDATED=false
|
|
|
|
for arg in "$@"; do
|
|
case "$arg" in
|
|
--no-reboot) REBOOT_ON_SUCCESS=false ;;
|
|
esac
|
|
done
|
|
|
|
# --- Configurazione host (override via /etc/weekly-maintenance.conf) ---
|
|
case "$(hostname -s)" in
|
|
pi1)
|
|
HOST_LABEL="Pi-1 (Master)"
|
|
DOCKER_IGNORE_IMAGES=("turni-app:live-latest")
|
|
;;
|
|
pi2)
|
|
HOST_LABEL="Pi-2 (Backup)"
|
|
CHECK_PIP3=true
|
|
DOCKER_IGNORE_IMAGES=("irrigazione:latest" "turni-app:beta-latest" "turni-app:alpha-latest")
|
|
;;
|
|
esac
|
|
|
|
if [[ -f "$CONF_FILE" ]]; then
|
|
# shellcheck source=/dev/null
|
|
source "$CONF_FILE"
|
|
fi
|
|
|
|
# --- Utility ---
|
|
log() {
|
|
printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE"
|
|
}
|
|
|
|
append_report() {
|
|
printf '%s\n' "$*" >> "$REPORT_FILE"
|
|
}
|
|
|
|
run_step() {
|
|
local title="$1"
|
|
shift
|
|
local output
|
|
local rc=0
|
|
|
|
log "▶ $title"
|
|
append_report ""
|
|
append_report "=== $title ==="
|
|
|
|
output="$("$@" 2>&1)" || rc=$?
|
|
if [[ -n "$output" ]]; then
|
|
append_report "$output"
|
|
log "$output"
|
|
fi
|
|
|
|
if [[ $rc -eq 0 ]]; then
|
|
NOTES+=("OK: $title")
|
|
log "✓ $title completato"
|
|
else
|
|
ERRORS+=("$title (exit $rc)")
|
|
log "✗ $title fallito (exit $rc)"
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
load_telegram_token() {
|
|
if [[ ! -f "$TOKEN_FILE" ]]; then
|
|
WARNINGS+=("Token Telegram assente ($TOKEN_FILE): notifiche disabilitate")
|
|
TELEGRAM_ENABLED=false
|
|
return 1
|
|
fi
|
|
BOT_TOKEN=$(tr -d '\n\r' < "$TOKEN_FILE")
|
|
if [[ -z "$BOT_TOKEN" ]]; then
|
|
WARNINGS+=("Token Telegram vuoto: notifiche disabilitate")
|
|
TELEGRAM_ENABLED=false
|
|
return 1
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
send_telegram() {
|
|
local msg="$1"
|
|
[[ "$TELEGRAM_ENABLED" == true ]] || return 0
|
|
curl -s --max-time 15 -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
|
|
-d "chat_id=${CHAT_ID}" \
|
|
--data-urlencode "text=${msg}" \
|
|
-d "parse_mode=Markdown" > /dev/null 2>&1 || true
|
|
}
|
|
|
|
send_telegram_document() {
|
|
[[ "$TELEGRAM_ENABLED" == true ]] || return 0
|
|
[[ -f "$REPORT_FILE" ]] || return 0
|
|
curl -s --max-time 30 -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendDocument" \
|
|
-F "chat_id=${CHAT_ID}" \
|
|
-F "document=@${REPORT_FILE}" \
|
|
-F "caption=Report completo ${HOST_LABEL}" > /dev/null 2>&1 || true
|
|
}
|
|
|
|
require_root() {
|
|
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
|
|
echo "Eseguire come root (es. cron root)." >&2
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
image_is_ignored() {
|
|
local image="$1"
|
|
local ignored
|
|
for ignored in "${DOCKER_IGNORE_IMAGES[@]}"; do
|
|
[[ "$image" == "$ignored" ]] && return 0
|
|
done
|
|
return 1
|
|
}
|
|
|
|
ensure_watchtower_labels() {
|
|
command -v docker >/dev/null 2>&1 || return 0
|
|
local name image
|
|
while IFS= read -r line; do
|
|
[[ -z "$line" ]] && continue
|
|
name=${line%%|*}
|
|
image=${line#*|}
|
|
image_is_ignored "$image" || continue
|
|
docker update --label-add com.centurylinklabs.watchtower.enable=false "$name" >/dev/null 2>&1 || true
|
|
done < <(docker ps --format '{{.Names}}|{{.Image}}' 2>/dev/null || true)
|
|
}
|
|
|
|
run_watchtower() {
|
|
command -v docker >/dev/null 2>&1 || return 0
|
|
|
|
ensure_watchtower_labels
|
|
|
|
log "▶ Watchtower (run-once, container aggiornabili da registry)"
|
|
append_report ""
|
|
append_report "=== Watchtower run-once ==="
|
|
|
|
local output rc=0
|
|
output=$(docker run --rm \
|
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
|
-e WATCHTOWER_CLEANUP=true \
|
|
-e WATCHTOWER_ROLLING_RESTART=false \
|
|
-e WATCHTOWER_TIMEOUT="${WATCHTOWER_TIMEOUT}" \
|
|
-e TZ=Europe/Rome \
|
|
"$WATCHTOWER_IMAGE" \
|
|
--run-once 2>&1) || rc=$?
|
|
|
|
if [[ -n "$output" ]]; then
|
|
append_report "$output"
|
|
printf '%s\n' "$output" | tail -30 | while IFS= read -r line; do log "$line"; done
|
|
fi
|
|
|
|
if [[ $rc -eq 0 ]]; then
|
|
NOTES+=("Watchtower run-once OK")
|
|
if grep -q "updated=[1-9]" <<< "$output"; then
|
|
DOCKER_UPDATED=true
|
|
REBOOT_RECOMMENDED=true
|
|
NOTES+=("Watchtower: container aggiornati")
|
|
fi
|
|
else
|
|
ERRORS+=("Watchtower run-once (exit $rc)")
|
|
fi
|
|
}
|
|
|
|
audit_apt() {
|
|
local holds
|
|
holds=$(apt-mark showhold 2>/dev/null || true)
|
|
if [[ -n "$holds" ]]; then
|
|
WARNINGS+=("Pacchetti apt bloccati (hold): ${holds//$'\n'/, }")
|
|
append_report "$holds"
|
|
fi
|
|
|
|
local upgradable
|
|
upgradable=$(apt list --upgradable 2>/dev/null | tail -n +2 || true)
|
|
if [[ -n "$upgradable" ]]; then
|
|
WARNINGS+=("Restano pacchetti non aggiornati dopo full-upgrade")
|
|
append_report "$upgradable"
|
|
fi
|
|
}
|
|
|
|
audit_pihole_versions() {
|
|
local out
|
|
out=$("$PIHOLE_BIN" -v 2>&1 || true)
|
|
append_report "$out"
|
|
if grep -qiE 'version is .*\(Latest: .*\)' <<< "$out"; then
|
|
while IFS= read -r line; do
|
|
local current latest
|
|
current=$(sed -n 's/.*Version is \([^ ]*\).*/\1/p' <<< "$line")
|
|
latest=$(sed -n 's/.*Latest: \([^)]*\).*/\1/p' <<< "$line")
|
|
if [[ -n "$current" && -n "$latest" && "$current" != "$latest" && "$current" != "N/A" ]]; then
|
|
WARNINGS+=("Pi-hole non allineato: $line")
|
|
fi
|
|
done <<< "$out"
|
|
fi
|
|
}
|
|
|
|
audit_eeprom() {
|
|
command -v rpi-eeprom-update >/dev/null 2>&1 || return 0
|
|
local out
|
|
out=$(rpi-eeprom-update 2>&1 || true)
|
|
append_report "$out"
|
|
if grep -qiE 'UPDATE REQUIRED|update available|NEW EEPROM' <<< "$out"; then
|
|
log "EEPROM: aggiornamento disponibile, applico..."
|
|
append_report ""
|
|
append_report "=== rpi-eeprom-update -a ==="
|
|
if rpi-eeprom-update -a >> "$REPORT_FILE" 2>&1; then
|
|
NOTES+=("EEPROM aggiornato")
|
|
REBOOT_RECOMMENDED=true
|
|
else
|
|
ERRORS+=("rpi-eeprom-update -a")
|
|
fi
|
|
else
|
|
NOTES+=("EEPROM: aggiornato")
|
|
fi
|
|
}
|
|
|
|
audit_docker() {
|
|
command -v docker >/dev/null 2>&1 || return 0
|
|
|
|
append_report ""
|
|
append_report "=== Docker audit ==="
|
|
|
|
local exited
|
|
exited=$(docker ps -a --filter "status=exited" --format '{{.Names}} ({{.Image}})' 2>/dev/null || true)
|
|
if [[ -n "$exited" ]]; then
|
|
WARNINGS+=("Container Docker fermati (Exited): ${exited//$'\n'/, }")
|
|
append_report "Exited:"
|
|
append_report "$exited"
|
|
fi
|
|
|
|
local name image
|
|
while IFS= read -r line; do
|
|
[[ -z "$line" ]] && continue
|
|
name=${line%%|*}
|
|
image=${line#*|}
|
|
|
|
image_is_ignored "$image" && continue
|
|
|
|
if [[ "$image" =~ ^[0-9a-f]{12}$ ]]; then
|
|
WARNINGS+=("Container '$name' usa immagine per ID ($image): preferire un tag versionato")
|
|
fi
|
|
|
|
local pinned
|
|
for pinned in "${DOCKER_PINNED_IMAGES[@]}"; do
|
|
if [[ "$image" == "$pinned" ]]; then
|
|
WARNINGS+=("Container '$name' usa tag bloccato ($pinned): Watchtower non passerà a :latest")
|
|
fi
|
|
done
|
|
done < <(docker ps --format '{{.Names}}|{{.Image}}' 2>/dev/null || true)
|
|
}
|
|
|
|
audit_pip3() {
|
|
[[ "$CHECK_PIP3" == true ]] || return 0
|
|
command -v pip3 >/dev/null 2>&1 || return 0
|
|
|
|
append_report ""
|
|
append_report "=== pip3 outdated (sistema) ==="
|
|
local outdated count
|
|
outdated=$(pip3 list --outdated --format=columns 2>/dev/null | tail -n +3 || true)
|
|
if [[ -n "$outdated" ]]; then
|
|
count=$(wc -l <<< "$outdated")
|
|
append_report "$outdated"
|
|
WARNINGS+=("pip3: $count pacchetti Python di sistema non aggiornati (aggiornamento manuale/consigliato in venv)")
|
|
else
|
|
NOTES+=("pip3: tutti aggiornati")
|
|
fi
|
|
}
|
|
|
|
build_telegram_summary() {
|
|
local icon status
|
|
if ((${#ERRORS[@]} > 0)); then
|
|
icon="🚨"
|
|
status="ERRORI"
|
|
elif ((${#WARNINGS[@]} > 0)); then
|
|
icon="⚠️"
|
|
status="WARNINGS"
|
|
else
|
|
icon="✅"
|
|
status="OK"
|
|
fi
|
|
|
|
local msg="${icon} *Manutenzione settimanale ${HOST_LABEL}*
|
|
Stato: *${status}*
|
|
Data: $(date '+%d/%m/%Y %H:%M')"
|
|
|
|
if [[ "$APT_CHANGED" == true ]]; then
|
|
msg+="
|
|
📦 apt: pacchetti aggiornati"
|
|
fi
|
|
if [[ "$DOCKER_UPDATED" == true ]]; then
|
|
msg+="
|
|
🐳 Docker: container aggiornati"
|
|
fi
|
|
if [[ "$REBOOT_ON_SUCCESS" == true ]]; then
|
|
msg+="
|
|
🔄 Reboot host programmato tra ${REBOOT_DELAY_MIN} min (fine manutenzione)"
|
|
fi
|
|
|
|
if ((${#ERRORS[@]} > 0)); then
|
|
msg+="
|
|
*Errori (${#ERRORS[@]}):*"
|
|
local e
|
|
for e in "${ERRORS[@]}"; do
|
|
msg+="
|
|
• ${e}"
|
|
done
|
|
fi
|
|
|
|
if ((${#WARNINGS[@]} > 0)); then
|
|
msg+="
|
|
*Avvisi (${#WARNINGS[@]}):*"
|
|
local w
|
|
local n=0
|
|
for w in "${WARNINGS[@]}"; do
|
|
((n++)) || true
|
|
[[ $n -le 8 ]] && msg+="
|
|
• ${w}"
|
|
done
|
|
if ((${#WARNINGS[@]} > 8)); then
|
|
msg+="
|
|
• … +$((${#WARNINGS[@]} - 8)) avvisi (vedi report)"
|
|
fi
|
|
fi
|
|
|
|
if ((${#ERRORS[@]} == 0 && ${#WARNINGS[@]} == 0)); then
|
|
msg+="
|
|
Tutto aggiornato. Log: \`${LOG_FILE}\`"
|
|
else
|
|
msg+="
|
|
Report completo allegato o in \`${REPORT_FILE}\`"
|
|
fi
|
|
|
|
send_telegram "$msg"
|
|
if ((${#ERRORS[@]} > 0 || ${#WARNINGS[@]} > 0)); then
|
|
send_telegram_document
|
|
fi
|
|
}
|
|
|
|
# --- Main ---
|
|
require_root
|
|
|
|
: > "$REPORT_FILE"
|
|
log "========== Inizio manutenzione settimanale ($HOST_LABEL) =========="
|
|
append_report "Manutenzione settimanale - $HOST_LABEL"
|
|
append_report "Avviata: $(date '+%Y-%m-%d %H:%M:%S')"
|
|
append_report "Host: $(hostname -f) ($(hostname -I | awk '{print $1}'))"
|
|
|
|
load_telegram_token || true
|
|
|
|
# 1. Aggiornamento pacchetti sistema
|
|
if apt update >> "$REPORT_FILE" 2>&1; then
|
|
NOTES+=("apt update OK")
|
|
log "✓ apt update"
|
|
else
|
|
ERRORS+=("apt update")
|
|
log "✗ apt update fallito"
|
|
fi
|
|
|
|
BEFORE_UPGRADES=$(apt list --upgradable 2>/dev/null | tail -n +2 | wc -l || echo 0)
|
|
if apt full-upgrade -y >> "$REPORT_FILE" 2>&1; then
|
|
NOTES+=("apt full-upgrade OK")
|
|
log "✓ apt full-upgrade"
|
|
AFTER_UPGRADES=$(apt list --upgradable 2>/dev/null | tail -n +2 | wc -l || echo 0)
|
|
if [[ "$BEFORE_UPGRADES" -gt "$AFTER_UPGRADES" ]] || [[ "$BEFORE_UPGRADES" -gt 0 ]]; then
|
|
APT_CHANGED=true
|
|
REBOOT_RECOMMENDED=true
|
|
fi
|
|
else
|
|
ERRORS+=("apt full-upgrade")
|
|
log "✗ apt full-upgrade fallito"
|
|
fi
|
|
|
|
run_step "apt autoremove" apt autoremove -y
|
|
audit_apt
|
|
|
|
# 2. Pi-hole
|
|
if [[ -x "$PIHOLE_BIN" ]]; then
|
|
run_step "pihole -up" "$PIHOLE_BIN" -up
|
|
audit_pihole_versions
|
|
else
|
|
ERRORS+=("pihole non trovato in $PIHOLE_BIN")
|
|
fi
|
|
|
|
# 3. EEPROM firmware
|
|
audit_eeprom
|
|
|
|
# 4. Aggiornamento container Docker (Watchtower run-once, sostituisce il daemon schedulato)
|
|
run_watchtower
|
|
|
|
# 5. Audit residui
|
|
audit_docker
|
|
audit_pip3
|
|
|
|
append_report ""
|
|
append_report "=== Riepilogo ==="
|
|
append_report "Errori: ${#ERRORS[@]}"
|
|
append_report "Avvisi: ${#WARNINGS[@]}"
|
|
if ((${#ERRORS[@]} > 0)); then
|
|
append_report "Dettaglio errori:"
|
|
printf '%s\n' "${ERRORS[@]}" >> "$REPORT_FILE"
|
|
fi
|
|
if ((${#WARNINGS[@]} > 0)); then
|
|
append_report "Dettaglio avvisi:"
|
|
printf '%s\n' "${WARNINGS[@]}" >> "$REPORT_FILE"
|
|
fi
|
|
append_report "Fine: $(date '+%Y-%m-%d %H:%M:%S')"
|
|
|
|
build_telegram_summary
|
|
|
|
# 6. Reboot fisso di conclusione (salvo --no-reboot o REBOOT_ON_SUCCESS=false in conf)
|
|
if [[ "$REBOOT_ON_SUCCESS" == true ]]; then
|
|
log "Reboot host programmato tra ${REBOOT_DELAY_MIN} minuti (fine manutenzione settimanale)"
|
|
append_report ""
|
|
append_report "=== Reboot ==="
|
|
append_report "Programmato tra ${REBOOT_DELAY_MIN} minuti"
|
|
/usr/sbin/shutdown -r "+${REBOOT_DELAY_MIN}" "Manutenzione settimanale ${HOST_LABEL}" || {
|
|
ERRORS+=("shutdown -r (reboot programmato)")
|
|
log "✗ Impossibile programmare il reboot"
|
|
}
|
|
else
|
|
log "Reboot disabilitato (--no-reboot o REBOOT_ON_SUCCESS=false)"
|
|
append_report ""
|
|
append_report "=== Reboot ==="
|
|
append_report "Saltato (disabilitato)"
|
|
fi
|
|
|
|
log "========== Fine manutenzione settimanale =========="
|
|
exit $(( ${#ERRORS[@]} > 0 ? 1 : 0 ))
|