#!/usr/bin/env bash # proxmox-backup-check.sh โ€” Weekly Proxmox backup verification โ†’ Discord # # SSHes to the Proxmox host and checks that every running VM/CT has a # successful vzdump backup within the last 7 days. Posts a color-coded # Discord summary with per-guest status. # # Usage: # proxmox-backup-check.sh [--discord-webhook URL] [--days N] [--dry-run] # # Environment overrides: # DISCORD_WEBHOOK Discord webhook URL (required unless --dry-run) # PROXMOX_NODE Proxmox node name (default: proxmox) # PROXMOX_SSH SSH alias or host for Proxmox (default: proxmox) # WINDOW_DAYS Backup recency window in days (default: 7) # # Install on CT 302 (weekly, Monday 08:00 UTC): # 0 8 * * 1 /root/scripts/proxmox-backup-check.sh >> /var/log/proxmox-backup-check.log 2>&1 set -uo pipefail PROXMOX_NODE="${PROXMOX_NODE:-proxmox}" PROXMOX_SSH="${PROXMOX_SSH:-proxmox}" WINDOW_DAYS="${WINDOW_DAYS:-7}" DISCORD_WEBHOOK="${DISCORD_WEBHOOK:-}" DRY_RUN=0 while [[ $# -gt 0 ]]; do case "$1" in --discord-webhook) if [[ $# -lt 2 ]]; then echo "Error: --discord-webhook requires a value" >&2 exit 1 fi DISCORD_WEBHOOK="$2" shift 2 ;; --days) if [[ $# -lt 2 ]]; then echo "Error: --days requires a value" >&2 exit 1 fi WINDOW_DAYS="$2" shift 2 ;; --dry-run) DRY_RUN=1 shift ;; *) echo "Unknown option: $1" >&2 exit 1 ;; esac done if [[ "$DRY_RUN" -eq 0 && -z "$DISCORD_WEBHOOK" ]]; then echo "Error: DISCORD_WEBHOOK not set. Use --discord-webhook URL or set env var." >&2 exit 1 fi if ! command -v jq &>/dev/null; then echo "Error: jq is required but not installed." >&2 exit 1 fi SSH_OPTS="-o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 -o BatchMode=yes" CUTOFF=$(date -d "-${WINDOW_DAYS} days" +%s) NOW=$(date +%s) log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; } # --------------------------------------------------------------------------- # Fetch data from Proxmox # --------------------------------------------------------------------------- log "Fetching VM and CT list from Proxmox node '${PROXMOX_NODE}'..." VMS_JSON=$(ssh $SSH_OPTS "$PROXMOX_SSH" \ "pvesh get /nodes/${PROXMOX_NODE}/qemu --output-format json 2>/dev/null" || echo "[]") CTS_JSON=$(ssh $SSH_OPTS "$PROXMOX_SSH" \ "pvesh get /nodes/${PROXMOX_NODE}/lxc --output-format json 2>/dev/null" || echo "[]") log "Fetching recent vzdump task history (limit 200)..." TASKS_JSON=$(ssh $SSH_OPTS "$PROXMOX_SSH" \ "pvesh get /nodes/${PROXMOX_NODE}/tasks --typefilter vzdump --limit 200 --output-format json 2>/dev/null" || echo "[]") # --------------------------------------------------------------------------- # Build per-guest backup status # --------------------------------------------------------------------------- # Merge VMs and CTs into one list: [{vmid, name, type}] GUESTS_JSON=$(jq -n \ --argjson vms "$VMS_JSON" \ --argjson cts "$CTS_JSON" ' ($vms | map(select(.status == "running") | {vmid: (.vmid | tostring), name, type: "VM"})) + ($cts | map(select(.status == "running") | {vmid: (.vmid | tostring), name, type: "CT"})) ') GUEST_COUNT=$(echo "$GUESTS_JSON" | jq 'length') log "Found ${GUEST_COUNT} running guests." # For each guest, find the most recent successful (status == "OK") vzdump task RESULTS=$(jq -n \ --argjson guests "$GUESTS_JSON" \ --argjson tasks "$TASKS_JSON" \ --argjson cutoff "$CUTOFF" \ --argjson now "$NOW" \ --argjson window "$WINDOW_DAYS" ' $guests | map( . as $g | ($tasks | map( select( (.vmid | tostring) == $g.vmid and .status == "OK" ) | .starttime ) | max // 0) as $last_ts | { vmid: $g.vmid, name: $g.name, type: $g.type, last_backup_ts: $last_ts, age_days: (if $last_ts > 0 then (($now - $last_ts) / 86400 | floor) else -1 end), status: ( if $last_ts >= $cutoff then "green" elif $last_ts > 0 then "yellow" else "red" end ) } ) | sort_by(.vmid | tonumber) ') GREEN_GUESTS=$(echo "$RESULTS" | jq '[.[] | select(.status == "green")]') YELLOW_GUESTS=$(echo "$RESULTS" | jq '[.[] | select(.status == "yellow")]') RED_GUESTS=$(echo "$RESULTS" | jq '[.[] | select(.status == "red")]') GREEN_COUNT=$(echo "$GREEN_GUESTS" | jq 'length') YELLOW_COUNT=$(echo "$YELLOW_GUESTS" | jq 'length') RED_COUNT=$(echo "$RED_GUESTS" | jq 'length') log "Results: ${GREEN_COUNT} green, ${YELLOW_COUNT} yellow, ${RED_COUNT} red" # --------------------------------------------------------------------------- # Build Discord payload # --------------------------------------------------------------------------- if [[ "$RED_COUNT" -gt 0 ]]; then EMBED_COLOR=15548997 # 0xED4245 red STATUS_LINE="๐Ÿ”ด Backup issues detected โ€” action required" elif [[ "$YELLOW_COUNT" -gt 0 ]]; then EMBED_COLOR=16705372 # 0xFF851C orange STATUS_LINE="๐ŸŸก Some backups are overdue (>${WINDOW_DAYS}d)" else EMBED_COLOR=5763719 # 0x57F287 green STATUS_LINE="๐ŸŸข All ${GUEST_COUNT} guests backed up within ${WINDOW_DAYS} days" fi # Format guest lines: "VM 116 (plex) โ€” 2d ago" or "CT 302 (claude-runner) โ€” NO BACKUPS" format_guest() { local prefix="$1" guests="$2" echo "$guests" | jq -r '.[] | "\(.type) \(.vmid) (\(.name))"' | while IFS= read -r line; do echo "${prefix} ${line}"; done } format_guest_with_age() { local prefix="$1" guests="$2" echo "$guests" | jq -r '.[] | "\(.type) \(.vmid) (\(.name)) โ€” \(.age_days)d ago"' | while IFS= read -r line; do echo "${prefix} ${line}"; done } # Build fields array fields='[]' if [[ "$GREEN_COUNT" -gt 0 ]]; then green_lines=$(format_guest_with_age "โœ…" "$GREEN_GUESTS") fields=$(echo "$fields" | jq \ --arg name "๐ŸŸข Healthy (${GREEN_COUNT})" \ --arg value "$green_lines" \ '. + [{"name": $name, "value": $value, "inline": false}]') fi if [[ "$YELLOW_COUNT" -gt 0 ]]; then yellow_lines=$(format_guest_with_age "โš ๏ธ" "$YELLOW_GUESTS") fields=$(echo "$fields" | jq \ --arg name "๐ŸŸก Overdue โ€” last backup >${WINDOW_DAYS}d ago (${YELLOW_COUNT})" \ --arg value "$yellow_lines" \ '. + [{"name": $name, "value": $value, "inline": false}]') fi if [[ "$RED_COUNT" -gt 0 ]]; then red_lines=$(format_guest "โŒ" "$RED_GUESTS") fields=$(echo "$fields" | jq \ --arg name "๐Ÿ”ด No Successful Backups Found (${RED_COUNT})" \ --arg value "$red_lines" \ '. + [{"name": $name, "value": $value, "inline": false}]') fi FOOTER="$(date -u '+%Y-%m-%d %H:%M UTC') ยท ${GUEST_COUNT} guests ยท window: ${WINDOW_DAYS}d" PAYLOAD=$(jq -n \ --arg title "Proxmox Backup Check โ€” ${STATUS_LINE}" \ --argjson color "$EMBED_COLOR" \ --argjson fields "$fields" \ --arg footer "$FOOTER" \ '{ "embeds": [{ "title": $title, "color": $color, "fields": $fields, "footer": {"text": $footer} }] }') if [[ "$DRY_RUN" -eq 1 ]]; then log "DRY RUN โ€” Discord payload:" echo "$PAYLOAD" | jq . exit 0 fi log "Posting to Discord..." HTTP_STATUS=$(curl -s -o /tmp/proxmox-backup-check-discord.out \ -w "%{http_code}" \ -X POST "$DISCORD_WEBHOOK" \ -H "Content-Type: application/json" \ -d "$PAYLOAD") if [[ "$HTTP_STATUS" -ge 200 && "$HTTP_STATUS" -lt 300 ]]; then log "Discord notification sent (HTTP ${HTTP_STATUS})." else log "Warning: Discord returned HTTP ${HTTP_STATUS}." cat /tmp/proxmox-backup-check-discord.out >&2 exit 1 fi