#!/bin/bash # # shell_proxmox_backup_copy.sh # # ----------------------------------- Beschreibung ----------------------------------- # Automatisches Kopieren von lokalen Proxmox-Backups nach zu einem Netzlaufwerk via rsync über SSH. # Dadurch wird sichergestellt, dass die Backups verlustfrei übertragen werden. # Im Gegensatz zu einer einfachen Samba-Übertragung prüft rsync die Integrität # und setzt abgebrochene Kopien zuverlässig fort # # -------------------------------------- Ablauf -------------------------------------- # Wird ein neues Backup im lokalen Proxmox-Verzeichnis (PROX_BACKUP_DIR) gefunden, wird: # 1. Das Backup mit „zstd -t“ auf Konsistenz geprüft. # Inkonsistente Backups werden als CORRUPT markiert und verbleiben im PROX_BACKUP_DIR. # 2. Das Backup per rsync über SSH nach SSH_DEST kopiert. # Unvollständige Dateien werden automatisch fortgesetzt. # 3. Die Quelldatei wird gelöscht, wenn die Prüfsumme mit der am Zielort übereinstimmt. # Andernfalls bleibt sie bestehen und wird beim nächsten Durchlauf erneut kopiert/fortgesetzt. # 4. Da die Backups auf ggf. mit einem anderen Benutzer übertragen wurden wird der Eigentümer nach # SSH_BACKUP_OWNER geändert. (Bei unRAID z.B. hat in der Regel nur root SSH Rechte) # 5. Entsprechend der Vorgabe BACKUPS_TO_KEEP werden im Quellverzeichnis die ältesten # Backups der jeweiligen VM/LXC gelöscht. # # -------------------------------------- SSH-Key ------------------------------------- # Damit das Skript ohne Passwortabfrage läuft, muss ein SSH-Key erstellt werden: # ssh-keygen -t ed25519 -f ~/.ssh/proxmox (Passwort leer lassen) # ssh-copy-id -i ~/.ssh/proxmox.pub root@192.168.178.100 # (IP von NAS ggf. anpassen, Passwort des root-Users eingeben) # # -------------------------------------- Aufruf -------------------------------------- # Das Skript lässt nur eine Instanz gleichzeitig zu. Es kann daher gefahrlos über cron # regelmäßig gestartet werden, z. B. alle 6 Stunden: # 0 */6 * * * /root/shell_proxmox_backup_copy.sh >> /var/log/backup_cron.log 2>&1 # oder um 02:00 Uhr # 0 2 * * * /root/shell_proxmox_backup_copy.sh >> /var/log/backup_cron.log 2>&1 # ---------------------------------- Konfiguration ----------------------------------- PROX_BACKUP_DIR="/var/lib/vz/dump" # Proxmox Verzeichnis in dem Lokale Backups landen SSH_USER="root" # Benutzer für die SSH Verbindung zum NAS SSH_BACKUP_OWNER="thorsten" # Auf diesen Benutzer werden die Backups am Zielort übertragen falls er von SSH_USER abweicht (chown) SSH_HOST="192.168.178.100" # IP des NAS Servers SSH_DEST="/mnt/user/Home Server/Volumes/Proxmox/dump" # Zielpfad / Ein per ssh erreichbarer Pfad auf dem NAS, kein lokaler Pfad auf Proxmox LOGFILE="/mnt/unRAID/Home Server/Volumes/Proxmox/dump/_shell_proxmox_backup_copy.log" # Logfile SSH_KEY="$HOME/.ssh/proxmox" # Pfad zum SSH Key BACKUPS_TO_KEEP=3 # Anzahl der neuesten Backups, die behalten werden # -------------------------------- Benachrichtigung ---------------------------------- NTFY_USE=true NTFY_TOPIC="Proxmox" NTFY_SERVER="192.168.178.25:5885" NTFY_AUTH="Bearer tk_jtt0zcnmephixstrp9tleb6klf0zu" NTFY_TITLE="Backup Übertragung" NTFY_ICON="" # ---------------------------------- Script Start ----------------------------------- # === Flag für Aufräumaktion === CLEANUP_REQUIRED=false # === Flag für Logging === LOG_ALLOWED=false # Die Logdatei nur beschreiben wenn es wirklich neue Backups gibt # === Variablen für den Bericht === BACKUPS_FOUND=0 BACKUPS_COPIED=0 BACKUPS_FAILED_CORRUPT=0 BACKUPS_FAILED_RSYNC=0 BACKUPS_FAILED_CHECKSUM=0 # Farbdefinitionen RED='\e[31m' GREEN='\e[32m' YELLOW='\e[33m' BLUE='\e[34m' NC='\e[0m' # No Color # === Logging Funktion === log() { local ts="$(date '+%F %T')" local msg="$1" # Konsolenausgabe: ts grau hinterlegt, msg normal echo -e "\e[100;97m $ts \e[0m $msg" # Logfile ohne ANSI-Codes, nur wenn LOG_ALLOWED=true if [ "$LOG_ALLOWED" = true ]; then local clean_msg clean_msg="$(echo -e "$msg" | sed -r 's/\x1B\[[0-9;]*[A-Za-z]//g')" echo "$ts $clean_msg" >> "$LOGFILE" fi } log_info() { log "${GREEN}[INFO]${NC} $1"; } log_warn() { log "${YELLOW}[WARN]${NC} $1"; } log_error() { log "${RED}[ERROR]${NC} $1"; } log_debug() { log "${BLUE}[DEBUG]${NC} $1"; } # Grau hinterlegte Logs (z.B. Start/Ende) log_highlight() { local ts="$(date '+%F %T')" # Konsole farbig echo -e "\e[100;97m $ts \e[0m \e[100;97m $1 \e[0m" # Logfile nur wenn LOG_ALLOWED=true if [ "$LOG_ALLOWED" = true ]; then echo "$ts $1" >> "$LOGFILE" fi } log_logfile_only() { local ts="$(date '+%F %T')" echo "$ts $1" >> "$LOGFILE" } # === Start-Info (immer Konsole, nie Logfile) === echo -e "\e[100;97m $(date '+%F %T') ──────────────────────────────────────────────────────────────── \e[0m" echo -e "\e[100;97m $(date '+%F %T') \e[0m \e[32m[INFO]\e[0m Starte das Übertragen von Backups" cleanup_old_backups() { log_info "Starte Aufräumen alter Backups im Zielverzeichnis, max $BACKUPS_TO_KEEP Backups pro VM/LXC behalten..." # Finde nur die echten Backup-Dateien. Die -not -name "*.notes" Anweisung filtert die .zst.notes Dateien aus. find_output=$($SSH_CMD "$SSH_USER@$SSH_HOST" "find \"$SSH_DEST\" -maxdepth 1 \( -name \"*.vma.zst\" -o -name \"*.tar.zst\" \) -not -name \"*.notes\"" || true) if [[ -z "$find_output" ]]; then log_info "Keine Backups zum Aufräumen gefunden." return fi # Gruppiere die Backups nach ihrem Prefix (VM/LXC-ID). declare -A BACKUP_GROUPS while IFS= read -r f; do FILE_BASENAME=$(basename "$f") PREFIX=$(echo "$FILE_BASENAME" | sed -E 's/-(20[0-9]{2}_[0-9]{2}_[0-9]{2}-[0-9]{2}_[0-9]{2}_[0-9]{2})\.(vma|tar)\.zst$//') BACKUP_GROUPS["$PREFIX"]+="$f"$'\n' done <<< "$find_output" # Durchsuche das Array nach leeren Einträgen und lösche sie. for PREFIX in "${!BACKUP_GROUPS[@]}"; do BACKUP_GROUPS["$PREFIX"]=$(echo -e "${BACKUP_GROUPS[$PREFIX]}" | grep -v '^$' || true) done for PREFIX in "${!BACKUP_GROUPS[@]}"; do readarray -t FILES_ZST < <(echo -e "${BACKUP_GROUPS[$PREFIX]}" | sort -V) COUNT=${#FILES_ZST[@]} log_info "Liste der gefundenen Backups für '$PREFIX':" for file_path in "${FILES_ZST[@]}"; do log_info " - $file_path" done log_info "Gefundene $COUNT Backups für VM/LXC '$PREFIX'." if [[ $COUNT -le $BACKUPS_TO_KEEP ]]; then log_info "Kein Löschen notwendig für '$PREFIX', nur $COUNT Backups gefunden (max $BACKUPS_TO_KEEP)." continue fi FILES_TO_DELETE=("${FILES_ZST[@]:0:$((COUNT - BACKUPS_TO_KEEP))}") log_info "Lösche $((${#FILES_TO_DELETE[@]})) älteste Backups für '$PREFIX'..." for f in "${FILES_TO_DELETE[@]}"; do base_filename=$(basename "$f") # Entfernt sowohl .tar.zst als auch .vma.zst, um den korrekten Basisnamen zu erhalten base_name=${base_filename%.vma.zst} base_name=${base_name%.tar.zst} #log_debug "DEBUG: Korrekter Basis-Name für die Löschung: $base_name" $SSH_CMD "$SSH_USER@$SSH_HOST" "rm -f \"$SSH_DEST/$base_name\"*" log_info "Lösche Backup: $base_name und zugehörige Dateien." done done log_info "Aufräumen alter Backups abgeschlossen." } # Pfad zur PID-Datei PIDFILE="/tmp/backup_cleanup.pid" # Prüfe, ob die PID-Datei existiert if [ -f "$PIDFILE" ]; then # Wenn ja, lies die PID PID=$(cat "$PIDFILE") # Prüfe, ob der Prozess mit dieser PID noch läuft if ps -p "$PID" > /dev/null; then # HIER WURDE "$PID" KORRIGIERT log_warn "Skript läuft bereits mit PID $PID. Beende." exit 1 else log_info "Veraltete PID-Datei gefunden, lösche sie..." rm "$PIDFILE" fi fi # Erstelle die neue PID-Datei mit der aktuellen PID echo $$ > "$PIDFILE" # Stelle sicher, dass die PID-Datei bei normalem Beenden oder Fehler gelöscht wird trap "rm -f \"$PIDFILE\"" EXIT # SSH-Befehl mit Key SSH_CMD="ssh -i $SSH_KEY -o StrictHostKeyChecking=no" # === Alle Backups im lokalen Verzeichnis (LXC: *.tar.zst, QEMU: *.vma.zst) === for BACKUP_FILE in "$PROX_BACKUP_DIR"/*.zst; do [[ -e "$BACKUP_FILE" ]] || continue # Wir haben eine Datei gefunden, also erhöhen wir den Zähler BACKUPS_FOUND=$((BACKUPS_FOUND+1)) # Wenn wir hier ankommen, heißt das: mindestens eine Backup-Datei liegt vor if [ "$LOG_ALLOWED" = false ]; then LOG_ALLOWED=true # Jetzt erst ins Logfile schreiben log_logfile_only "────────────────────────────────────────────────────────────────" log_logfile_only "[INFO] Starte das Übertragen von Backups" fi # Defekte Backups überspringen if [[ "$BACKUP_FILE" == *_CORRUPT.zst ]]; then log_warn "Überspringe defektes Backup: $(basename "$BACKUP_FILE")" BACKUPS_FAILED_CORRUPT=$((BACKUPS_FAILED_CORRUPT+1)) continue fi # Dateiendung entfernen (.vma.zst oder .tar.zst) BASENAME=$(basename "$BACKUP_FILE" | sed -E 's/\.(vma|tar)\.zst$//') FILES_TO_COPY=("$PROX_BACKUP_DIR/$BASENAME".*) log_info "Verarbeite Backup $BASENAME" # Prüfen, ob Backup fertig ist (Dateigröße stabil) log_info "Überprüfe ob das Backup in Bearbeitung ist..." SIZE1=$(stat -c %s "$BACKUP_FILE") sleep 10 SIZE2=$(stat -c %s "$BACKUP_FILE") if [[ "$SIZE1" != "$SIZE2" ]]; then log_info "Backup noch in Bearbeitung, überspringe vorerst: $BASENAME" continue fi # zstd Test log_info "Backup wird überprüft (zstd -t $BACKUP_FILE)" zstd -t "$BACKUP_FILE" if [[ $? -ne 0 ]]; then log_error "Backup defekt, wird nicht kopiert: $BASENAME" BACKUPS_FAILED_CORRUPT=$((BACKUPS_FAILED_CORRUPT+1)) # Alle Dateien der Gruppe umbenennen mit _CORRUPT for f in "${FILES_TO_COPY[@]}"; do mv "$f" "${f}_CORRUPT" done log_info "Backup umbenannt: ${BASENAME}_CORRUPT" continue fi # Zielverzeichnis auf dem NAS erstellen $SSH_CMD "$SSH_USER@$SSH_HOST" "mkdir -p \"$SSH_DEST\"" # Exakt denselben Dateinamen wie lokal verwenden (egal ob .tar.zst oder .vma.zst) REMOTE_FILE="$SSH_DEST/$(basename "$BACKUP_FILE")" log_info "Starte Kopie zum Ziel mit rsync" rsync -avh --progress --partial --checksum -e "$SSH_CMD" "${FILES_TO_COPY[@]}" "$SSH_USER@$SSH_HOST:$SSH_DEST" RSYNC_STATUS=$? if [[ $RSYNC_STATUS -ne 0 ]]; then log_error "rsync Fehlercode $RSYNC_STATUS für $BASENAME" BACKUPS_FAILED_RSYNC=$((BACKUPS_FAILED_RSYNC+1)) continue fi # Prüfen Checksumme der .zst auf Ziel (gleicher Dateiname wie lokal) log_info "Kopieren abgeschlossen. Checksumme wird überprüft..." LOCAL_SUM=$(sha256sum "$BACKUP_FILE" | awk '{print $1}') REMOTE_SUM=$($SSH_CMD "$SSH_USER@$SSH_HOST" "sha256sum \"$REMOTE_FILE\"" | awk '{print $1}') if [[ "$LOCAL_SUM" == "$REMOTE_SUM" ]]; then log_info "Checksumme stimmt." # Eigentümer ggf. anpassen if $SSH_USERS <> $SSH_BACKUP_OWNER; then log_info "Setze Besitzer auf $SSH_BACKUP_OWNER für Backup $BASENAME" $SSH_CMD "$SSH_USER@$SSH_HOST" "chown $SSH_BACKUP_OWNER \"$SSH_DEST/$BASENAME\".*" fi # === Flag setzen, da Kopie erfolgreich war === CLEANUP_REQUIRED=true BACKUPS_COPIED=$((BACKUPS_COPIED+1)) # Erfolgreiche Backup auf Proxmox löschen log_info "Lösche Backup $BASENAME auf Proxmox..." rm -f "${FILES_TO_COPY[@]}" else log_error "Checksumme stimmt nicht überein! Backup wird nicht gelöscht." BACKUPS_FAILED_CHECKSUM=$((BACKUPS_FAILED_CHECKSUM+1)) continue fi done # === Bedingter Aufruf der Aufräumfunktion === if $CLEANUP_REQUIRED; then cleanup_old_backups else log_info "Keine neuen Backups übertragen. Aufräumen wird übersprungen." fi # === Ende-Log === log_info "Abgeschlossen!" log_highlight "────────────────────────────────────────────────────────────────" # === NEU: ntfy Push Benachrichtigung === if [[ "$BACKUPS_FOUND" -gt 0 && "$NTFY_USE" == "true" ]]; then log_info "NTFY Zusammenfassung wird gesendet" # Setze Priorität auf "high", wenn es Fehler gab NTFY_PRIORITY="default" NTFY_TAGS="heavy_check_mark" MESSAGE_BODY="Neu Proxmox Backups wurden auf Fehler überprüft und erfolgreich aufs NAS Server übetragen.\n\n" if [ "$BACKUPS_FAILED_CORRUPT" -gt 0 ] || [ "$BACKUPS_FAILED_RSYNC" -gt 0 ] || [ "$BACKUPS_FAILED_CHECKSUM" -gt 0 ]; then MESSAGE_BODY="Es gibt Fehlerhafte Backups!\n\n" NTFY_PRIORITY="high" NTFY_TAGS="skull" fi # Nachrichtentext für den PUT-Befehl MESSAGE_BODY+="${BACKUPS_FOUND} gefundene Backups\n" MESSAGE_BODY+="${BACKUPS_COPIED} erfolgreich kopiert\n" if [ "$BACKUPS_FAILED_CORRUPT" -gt 0 ]; then MESSAGE_BODY+="${BACKUPS_FAILED_CORRUPT} Fehlerhaft (zstd)\n" fi if [ "$BACKUPS_FAILED_RSYNC" -gt 0 ]; then MESSAGE_BODY+="${BACKUPS_FAILED_RSYNC} Fehlerhaft (rsync)\n" fi if [ "$BACKUPS_FAILED_CHECKSUM" -gt 0 ]; then MESSAGE_BODY+="${BACKUPS_FAILED_CHECKSUM} Fehlerhaft (Checksumme)\n" fi # Hier wird der Nachrichtentext direkt an den curl-Befehl übergeben # Dies ist der einfachste Weg, um Zeilenumbrüche zu gewährleisten echo -e "$MESSAGE_BODY" | curl -s -X POST \ -H "Authorization: $NTFY_AUTH" \ -H "X-Priority: $NTFY_PRIORITY" \ -H "X-Title: $NTFY_TITLE" \ -H "X-Tags: $NTFY_TAGS" \ -H "X-Icon: $NTFY_ICON" \ --data-binary @- \ "http://$NTFY_SERVER/$NTFY_TOPIC" fi