#!/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 ))