#!/bin/bash # Sichert die Freigaben von bfds auf die OFFSITE-Platte (VeraCrypt-Partition). # macOS-Portierung von robocopy-bfds-offsite/bfds-offsite-backup.cmd. # Kompatibel mit dem mit macOS ausgelieferten Bash 3.2. # # Ablauf: VeraCrypt-Partition entschluesseln und einbinden, SMB-Freigaben von # bfds per rsync kopieren, Partition wieder aushaengen. # # Voraussetzung: VeraCrypt (+ macFUSE) installiert. # Optional vorab setzen: DEVICE=/dev/disk4s1 ./backup-bfds-storage-offsite.sh PATH="/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:/opt/homebrew/bin" export PATH # Schlaf verhindern, solange dieses Script laeuft (-w $$ endet mit dem Script). # -i Idle-Sleep, -m Platten-Sleep, -s System-Sleep (nur am Netzteil wirksam). # Der Bildschirm darf ausgehen. caffeinate -i -m -s -w $$ & SHARES=( photos rob movies music peggy ebooks ) VOLUME_LABEL="${VOLUME_LABEL:-NASOFFSITE1}" VERACRYPT="${VERACRYPT:-/Applications/VeraCrypt.app/Contents/MacOS/VeraCrypt}" MOUNT_POINT="${MOUNT_POINT:-/Volumes/$VOLUME_LABEL}" SOURCE_HOST="${SOURCE_HOST:-bfds}" DEVICE="${DEVICE:-}" MOUNTED_BY_US=0 # Run-Protokoll: pro Lauf eine JSON-Zeile, aus der das Dashboard den letzten # durchgehenden Run (alle Freigaben fehlerfrei) abliest. RUN_LOG_FILE="${RUN_LOG_FILE:-}" RUN_START="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" SHARE_RESULTS="" log() { printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" } die() { log "FEHLER: $*" exit 1 } unmount_volume() { [ "$MOUNTED_BY_US" = "1" ] || return 0 log "Partition wird ausgehaengt" "$VERACRYPT" --text --dismount "$MOUNT_POINT" --non-interactive \ || log "WARNUNG: Aushaengen fehlgeschlagen, bitte in VeraCrypt manuell trennen" MOUNTED_BY_US=0 } # Ergebnis einer Freigabe merken: ok, source_missing, unreadable oder rsync_ record_share() { SHARE_RESULTS="${SHARE_RESULTS:+$SHARE_RESULTS,}\"$1\":\"$2\"" } # Haengt am Ende jedes Laufs (auch bei Fehler oder Abbruch) eine JSON-Zeile an # ~/Library/Logs/bfds-offsite-backup.jsonl an. "complete" ist nur wahr, wenn das # Script mit 0 endete und jede Freigabe "ok" war. write_run_record() { local rc="$1" end ok total="${#SHARES[@]}" complete=false home end="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" ok="$(printf '%s' "$SHARE_RESULTS" | grep -o ':"ok"' | wc -l | tr -d ' ')" [ "$rc" -eq 0 ] && [ "$ok" -eq "$total" ] && complete=true if [ -z "$RUN_LOG_FILE" ]; then home="$HOME" if [ -n "${SUDO_USER:-}" ]; then home="$(dscl . -read "/Users/$SUDO_USER" NFSHomeDirectory 2>/dev/null | awk '{print $2}')" [ -n "$home" ] || home="$HOME" fi RUN_LOG_FILE="$home/Library/Logs/bfds-offsite-backup.jsonl" fi mkdir -p "$(dirname "$RUN_LOG_FILE")" 2>/dev/null printf '{"start":"%s","end":"%s","exit_code":%s,"complete":%s,"shares":{%s}}\n' \ "$RUN_START" "$end" "$rc" "$complete" "$SHARE_RESULTS" >> "$RUN_LOG_FILE" \ || { log "WARNUNG: Run-Protokoll konnte nicht geschrieben werden: $RUN_LOG_FILE"; return 0; } # Unter sudo gehoert die Datei sonst root if [ -n "${SUDO_USER:-}" ]; then chown "$SUDO_USER" "$RUN_LOG_FILE" 2>/dev/null || true; fi } # Die Disk-Nummer wechselt (disk4, disk6, ...). Gesucht wird daher die # Partition 1 auf externen, physischen Platten, die >= MIN_SIZE_GB gross sind # und nicht von macOS eingebunden werden koennen (Typ "Microsoft Basic Data", # verschluesselt). Nur bei genau einem Treffer wird sie verwendet. MIN_SIZE_GB="${MIN_SIZE_GB:-3000}" find_offsite_partition() { local disk part bytes local found=() for disk in $(diskutil list external physical | awk '/^\/dev\/disk/ {gsub(/[:]/,"",$1); sub("/dev/","",$1); print $1}'); do part="${disk}s1" diskutil info "$part" >/dev/null 2>&1 || continue diskutil list "$disk" | grep -q "Microsoft Basic Data.*${part}\$" || continue bytes="$(diskutil info "$disk" | sed -n 's/.*Disk Size:.*(\([0-9]*\) Bytes).*/\1/p' | head -1)" [ -n "$bytes" ] && [ "$bytes" -ge $((MIN_SIZE_GB * 1000000000)) ] || continue # Schon eingebundene Partition (normales Dateisystem) ist nicht die gesuchte [ -z "$(diskutil info "$part" | sed -n 's/^ *Mount Point: *\(.\+\)$/\1/p')" ] || continue found+=("/dev/$part") done if [ "${#found[@]}" -ne 1 ]; then log "FEHLER: Keine eindeutige OFFSITE-Partition gefunden (Treffer: ${#found[@]})" >&2 return 1 fi log "OFFSITE-Partition erkannt: ${found[0]}" >&2 echo "${found[0]}" } trap 'rc=$?; unmount_volume; write_run_record "$rc"' EXIT trap 'log "Abgebrochen"; exit 130' INT TERM # ************************************************** # Einbinden der VeraCrypt Partition # ************************************************** [ -x "$VERACRYPT" ] || die "VeraCrypt nicht gefunden: $VERACRYPT" if [ -d "$MOUNT_POINT" ] && mount | grep -q " on $MOUNT_POINT "; then log "Festplatte $VOLUME_LABEL ist bereits eingebunden: $MOUNT_POINT" else if [ -z "$DEVICE" ]; then DEVICE="$(find_offsite_partition)" || exit 1 fi # "disk4s1" und "/dev/disk4s1" akzeptieren case "$DEVICE" in disk*) DEVICE="/dev/$DEVICE" ;; esac [ -e "$DEVICE" ] || die "Device nicht gefunden: $DEVICE" read -r -s -p "Bitte geben Sie das Passwort fuer die Festplatte $VOLUME_LABEL ein: " VOLUME_PASSWORD echo log "Festplatte $VOLUME_LABEL wird entschluesselt und eingebunden" "$VERACRYPT" --text --non-interactive --mount "$DEVICE" "$MOUNT_POINT" \ --password="$VOLUME_PASSWORD" --pim=0 --keyfiles="" --protect-hidden=no \ || die "Einbinden mit VeraCrypt fehlgeschlagen" unset VOLUME_PASSWORD MOUNTED_BY_US=1 fi [ -d "$MOUNT_POINT" ] || die "Festplatte $VOLUME_LABEL nicht gefunden unter $MOUNT_POINT" # ************************************************** # Kopieren der SMB-Freigaben # ************************************************** EXIT_CODE=0 PROBE_TIMEOUT="${PROBE_TIMEOUT:-20}" RSYNC_TIMEOUT="${RSYNC_TIMEOUT:-60}" RSYNC_RETRIES="${RSYNC_RETRIES:-3}" # Fuehrt einen Befehl mit Zeitlimit (Sekunden) aus; macOS hat kein timeout. # Ein haengender Prozess (z.B. ls auf toter SMB-Verbindung) wird nicht # abgewartet, sondern abgeschossen und zurueckgelassen. run_with_timeout() { local secs="$1" pid ticks=0 shift "$@" >/dev/null 2>&1 & pid=$! while kill -0 "$pid" 2>/dev/null; do if [ "$ticks" -ge $((secs * 10)) ]; then kill -9 "$pid" 2>/dev/null return 124 fi sleep 0.1 ticks=$((ticks + 1)) done wait "$pid" } # Prueft, ob die Freigabe wirklich gemountet ist, Verzeichnisse listbar sind # und sich Dateiinhalt lesen laesst (Listing allein kann aus dem Cache kommen). probe_source() { local dir="$1" file mount | grep -q " on $dir (smbfs" || return 1 ls -A "$dir" >/dev/null || return 1 file="$(find "$dir" -maxdepth 3 -type f ! -name '.*' -print -quit)" || return 1 [ -z "$file" ] || head -c 1 "$file" >/dev/null || return 1 } run_rsync() { local share="$1" local source="/Volumes/$share" if [ ! -d "$source" ]; then log "Binde SMB-Freigabe smb://$SOURCE_HOST/$share ein" # Unter sudo als eigentlicher Benutzer oeffnen, damit dessen Keychain/Finder genutzt wird if [ -n "${SUDO_USER:-}" ]; then sudo -u "$SUDO_USER" open "smb://$SOURCE_HOST/$share" else open "smb://$SOURCE_HOST/$share" fi local i for i in 1 2 3 4 5 6 7 8 9 10; do [ -d "$source" ] && break sleep 1 done fi if [ ! -d "$source" ]; then log "WARNUNG: Quelle nicht gefunden, bitte SMB-Freigabe oeffnen: smb://$SOURCE_HOST/$share" EXIT_CODE=1 record_share "$share" source_missing return fi log "Pruefe Lesbarkeit von $source (Timeout ${PROBE_TIMEOUT}s)" if ! run_with_timeout "$PROBE_TIMEOUT" probe_source "$source"; then log "WARNUNG: $source nicht lesbar (haengt oder Fehler), ueberspringe $share. Freigabe im Finder trennen und neu verbinden." EXIT_CODE=1 record_share "$share" unreadable return fi log "Sichere $share" # -r Unterverzeichnisse inkl. leerer, -l Symlinks, -t Zeitstempel (wie /COPY:DAT), # --partial setzt abgebrochene Uebertragungen fort (wie /z). Kein --delete: # robocopy /E spiegelt ebenfalls nicht. --timeout bricht bei SMB-Haengern ab. # Bei Abbruch (z.B. SMB-Timeout) bis zu RSYNC_RETRIES mal weiter; --partial und # der Zeitstempelvergleich sorgen dafuer, dass Fertiges nicht neu kopiert wird. local rc attempt=1 while :; do rsync -rlt -h -v --progress --partial --stats --timeout="$RSYNC_TIMEOUT" --exclude=".DS_Store" --exclude="._.DS_Store" "$source/" "$MOUNT_POINT/$share/" rc=$? [ "$rc" -eq 0 ] && break [ "$attempt" -lt "$RSYNC_RETRIES" ] || break attempt=$((attempt + 1)) log "rsync fuer $share endete mit Code $rc, neuer Versuch $attempt/$RSYNC_RETRIES in 15s" sleep 15 run_with_timeout "$PROBE_TIMEOUT" probe_source "$source" || { log "WARNUNG: $source weiterhin nicht lesbar"; break; } done if [ "$rc" -ne 0 ]; then log "WARNUNG: rsync fuer $share endete mit Code $rc" EXIT_CODE=1 record_share "$share" "rsync_$rc" else record_share "$share" ok fi } for share in "${SHARES[@]}"; do run_rsync "$share" done log "Backupprozess beendet" exit "$EXIT_CODE"