#!/bin/bash # Unkomprimierter, inkrementell aktualisierter Paperless-Export auf diesen Mac. # Kompatibel mit dem mit macOS ausgelieferten Bash 3.2. set -euo pipefail PATH="/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:/opt/homebrew/bin" export PATH SERVER="${SERVER:-bfds}" APP_NAME="${APP_NAME:-paperless-ngx}" PAPERLESS_CONTAINER="${PAPERLESS_CONTAINER:-ix-paperless-ngx-paperless-1}" BACKUP_DIR="${BACKUP_DIR:-$HOME/Backups/paperless-ngx}" SSH_CONNECT_TIMEOUT="${SSH_CONNECT_TIMEOUT:-10}" NOTIFY="${NOTIFY:-1}" CONFIG_FILE="${PAPERLESS_BACKUP_CONFIG:-$HOME/.config/paperless-backup/config}" LOG_FILE="${PAPERLESS_BACKUP_LOG:-$HOME/Library/Logs/paperless-ngx-backup.log}" if [ -f "$CONFIG_FILE" ]; then # Die Datei ist eine benutzereigene Shell-Konfiguration (KEY=WERT). # shellcheck disable=SC1090 . "$CONFIG_FILE" fi SSH=(/usr/bin/ssh -o BatchMode=yes -o ConnectTimeout="$SSH_CONNECT_TIMEOUT") RSYNC_RSH="/usr/bin/ssh -o BatchMode=yes -o ConnectTimeout=$SSH_CONNECT_TIMEOUT" APP_CONFIG_DIR="/mnt/.ix-apps/app_configs/$APP_NAME" REMOTE_CURRENT_DIR="/usr/src/paperless/export/macos-backup-current" LOCAL_CURRENT_DIR="$BACKUP_DIR/current" LOCK_DIR="${TMPDIR:-/tmp}/paperless-ngx-backup-${UID}.lock" LOCAL_CHECK_DIR="" REMOTE_EXPORT_DIR="" REMOTE_EXPORT_HOST_DIR="" BACKUP_SUCCEEDED=0 usage() { cat <<'EOF' Aufruf: backup-paperless-ngx.sh [--check | --help] ohne Option Lokalen Paperless-Export inkrementell aktualisieren --check Zusaetzlich einen temporaeren Datenbankexport testen --help Diese Hilfe anzeigen Optionale Konfiguration: ~/.config/paperless-backup/config EOF } log() { local message message="[$(date '+%Y-%m-%d %H:%M:%S')] $*" printf '%s\n' "$message" | /usr/bin/tee -a "$LOG_FILE" } warn() { log "WARNUNG: $*" } notify() { [ "$NOTIFY" = "1" ] || return 0 /usr/bin/osascript - "$1" "$2" >/dev/null 2>&1 <<'APPLESCRIPT' || true on run argv display notification (item 2 of argv) with title "Paperless-ngx Backup" subtitle (item 1 of argv) end run APPLESCRIPT } die() { log "FEHLER: $*" exit 1 } cleanup() { local exit_code=$? trap - EXIT INT TERM set +e if [ -n "$REMOTE_EXPORT_DIR" ]; then case "$REMOTE_EXPORT_DIR" in /usr/src/paperless/export/macos-backup-check-*) "${SSH[@]}" "$SERVER" \ "docker exec '$PAPERLESS_CONTAINER' rm -rf -- '$REMOTE_EXPORT_DIR'" \ >/dev/null 2>&1 || true ;; esac fi if [ -n "$LOCAL_CHECK_DIR" ] && [ -d "$LOCAL_CHECK_DIR" ]; then case "$LOCAL_CHECK_DIR" in "$BACKUP_DIR"/.check.*) /bin/rm -rf -- "$LOCAL_CHECK_DIR" ;; esac fi /bin/rmdir "$LOCK_DIR" >/dev/null 2>&1 || true if [ "$exit_code" -ne 0 ] && [ "$BACKUP_SUCCEEDED" -ne 1 ]; then notify "Fehlgeschlagen" "Details: $LOG_FILE" fi exit "$exit_code" } validate_configuration() { case "$SERVER" in *[!A-Za-z0-9_.:@-]*|'') die "Ungueltiger SERVER-Wert: $SERVER" ;; esac case "$APP_NAME" in *[!A-Za-z0-9_.-]*|'') die "Ungueltiger APP_NAME-Wert: $APP_NAME" ;; esac case "$PAPERLESS_CONTAINER" in *[!A-Za-z0-9_.-]*|'') die "Ungueltiger Containername: $PAPERLESS_CONTAINER" ;; esac case "$SSH_CONNECT_TIMEOUT" in *[!0-9]*|'') die "SSH_CONNECT_TIMEOUT muss eine ganze Zahl sein" ;; esac case "$BACKUP_DIR" in /*) ;; *) die "BACKUP_DIR muss ein absoluter Pfad sein" ;; esac [ "$BACKUP_DIR" != "/" ] || die "Das Wurzelverzeichnis darf nicht BACKUP_DIR sein" } check_remote() { local running log "Pruefe SSH-Verbindung zu $SERVER ..." "${SSH[@]}" "$SERVER" true >/dev/null 2>&1 || \ die "SSH-Verbindung fehlgeschlagen (fuer launchd ist ein SSH-Key ohne Rueckfrage erforderlich)" running=$("${SSH[@]}" "$SERVER" \ "docker inspect --format='{{.State.Running}}' '$PAPERLESS_CONTAINER' 2>/dev/null") || \ die "Paperless-Container $PAPERLESS_CONTAINER wurde nicht gefunden" [ "$running" = "true" ] || die "Paperless-Container laeuft nicht" "${SSH[@]}" "$SERVER" "test -d '$APP_CONFIG_DIR' && test -r '$APP_CONFIG_DIR'" || \ die "TrueNAS-App-Konfiguration ist nicht lesbar: $APP_CONFIG_DIR" REMOTE_EXPORT_HOST_DIR=$("${SSH[@]}" "$SERVER" \ "docker inspect --format='{{range .Mounts}}{{if eq .Destination \"/usr/src/paperless/export\"}}{{.Source}}{{end}}{{end}}' '$PAPERLESS_CONTAINER'") case "$REMOTE_EXPORT_HOST_DIR" in /mnt/.ix-apps/docker/volumes/*/_data) ;; *) die "Export-Mount des Paperless-Containers konnte nicht sicher ermittelt werden" ;; esac # document_exporter wird vom Container-Wrapper von root auf den Benutzer # paperless heruntergestuft. Daher muessen Check und Ordnererstellung mit # genau diesem Benutzer erfolgen. "${SSH[@]}" "$SERVER" \ "docker exec --user paperless:paperless '$PAPERLESS_CONTAINER' test -w /usr/src/paperless/export" || \ die "Paperless-Exportverzeichnis ist nicht beschreibbar" log "Paperless-Container und TrueNAS-Speicherorte sind bereit" } write_backup_info() { local info_file="$1" { printf 'Backup created on Mac: %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" printf 'SSH server: %s\n' "$SERVER" printf 'TrueNAS app name: %s\n' "$APP_NAME" printf 'Paperless container: %s\n' "$PAPERLESS_CONTAINER" "${SSH[@]}" "$SERVER" "printf 'TrueNAS version: '; cat /etc/version; printf '\nContainer image (Paperless version): '; docker inspect --format='{{.Config.Image}}' '$PAPERLESS_CONTAINER'" } > "$info_file" } check_capacity() { local source_kb remote_current_kb local_current_kb local remote_needed_kb local_needed_kb remote_free_kb local_free_kb source_kb=$("${SSH[@]}" "$SERVER" \ "du -sk /mnt/.ix-apps/app_mounts/'$APP_NAME'/media /mnt/.ix-apps/app_mounts/'$APP_NAME'/data 2>/dev/null | awk '{s+=\$1} END {print s+0}'") || source_kb=0 remote_free_kb=$("${SSH[@]}" "$SERVER" \ "df -Pk '$REMOTE_EXPORT_HOST_DIR' | awk 'NR==2 {print \$4}'") || remote_free_kb=0 local_free_kb=$(/bin/df -Pk "$BACKUP_DIR" | /usr/bin/awk 'NR==2 {print $4}') remote_current_kb=$("${SSH[@]}" "$SERVER" \ "du -sk '$REMOTE_EXPORT_HOST_DIR/macos-backup-current' 2>/dev/null | awk '{print \$1+0}'") || remote_current_kb=0 local_current_kb=$(/usr/bin/du -sk "$LOCAL_CURRENT_DIR/paperless-export" 2>/dev/null | /usr/bin/awk '{print $1+0}') || local_current_kb=0 if [ "$source_kb" -gt 0 ]; then log "Geschaetzte Paperless-Datenmenge: $((source_kb / 1024)) MB" remote_needed_kb=$((source_kb - remote_current_kb)) local_needed_kb=$((source_kb - local_current_kb)) [ "$remote_needed_kb" -gt 0 ] || remote_needed_kb=0 [ "$local_needed_kb" -gt 0 ] || local_needed_kb=0 [ "$remote_free_kb" -gt "$remote_needed_kb" ] || \ warn "Auf dem TrueNAS-Apps-Pool koennte der Platz fuer den Export knapp sein" [ "$local_free_kb" -gt "$local_needed_kb" ] || \ warn "Auf dem Mac koennte der Platz fuer den lokalen Export knapp sein" fi } check_document_exporter() { local check_id check_host_dir check_id="$(date '+%Y-%m-%d_%H-%M-%S')-$$" REMOTE_EXPORT_DIR="/usr/src/paperless/export/macos-backup-check-$check_id" check_host_dir="$REMOTE_EXPORT_HOST_DIR/macos-backup-check-$check_id" LOCAL_CHECK_DIR=$(/usr/bin/mktemp -d "$BACKUP_DIR/.check.XXXXXX") log "Teste document_exporter und inkrementelle Aktualisierung ..." if ! "${SSH[@]}" "$SERVER" \ "docker exec --user paperless:paperless '$PAPERLESS_CONTAINER' mkdir -p '$REMOTE_EXPORT_DIR' && docker exec '$PAPERLESS_CONTAINER' document_exporter --no-progress-bar --data-only --compare-json --delete --use-folder-prefix '$REMOTE_EXPORT_DIR' && docker exec '$PAPERLESS_CONTAINER' document_exporter --no-progress-bar --data-only --compare-json --delete --use-folder-prefix '$REMOTE_EXPORT_DIR' && docker exec '$PAPERLESS_CONTAINER' test -s '$REMOTE_EXPORT_DIR/manifest.json'" \ 2>&1 | /usr/bin/tee -a "$LOG_FILE"; then die "Test des document_exporter ist fehlgeschlagen" fi log "Teste inkrementelle rsync-Uebertragung auf den Mac ..." /usr/bin/rsync -a --delete --no-owner --no-group -e "$RSYNC_RSH" \ "$SERVER:$check_host_dir/" "$LOCAL_CHECK_DIR/" \ >> "$LOG_FILE" 2>&1 || die "Test der rsync-Uebertragung ist fehlgeschlagen" [ -s "$LOCAL_CHECK_DIR/manifest.json" ] || \ die "Der rsync-Test hat kein gueltiges Manifest uebertragen" "${SSH[@]}" "$SERVER" \ "docker exec '$PAPERLESS_CONTAINER' rm -rf -- '$REMOTE_EXPORT_DIR'" \ >/dev/null 2>&1 || die "Temporaerer Testexport konnte nicht entfernt werden" REMOTE_EXPORT_DIR="" /bin/rm -rf -- "$LOCAL_CHECK_DIR" LOCAL_CHECK_DIR="" log "document_exporter, Aktualisierung, Manifest und rsync erfolgreich getestet" } main() { local mode="backup" export_size remote_current_host case "${1:-}" in '') ;; --check) mode="check" ;; --help|-h) usage; exit 0 ;; *) usage >&2; exit 2 ;; esac case "$BACKUP_DIR" in /*) ;; *) printf 'FEHLER: BACKUP_DIR muss ein absoluter Pfad sein\n' >&2; exit 1 ;; esac [ "$BACKUP_DIR" != "/" ] || { printf 'FEHLER: Ungueltiger BACKUP_DIR\n' >&2; exit 1; } /bin/mkdir -p "$(dirname "$LOG_FILE")" "$BACKUP_DIR" /bin/chmod 700 "$BACKUP_DIR" 2>/dev/null || true validate_configuration if ! /bin/mkdir "$LOCK_DIR" 2>/dev/null; then die "Ein Paperless-Backup laeuft bereits (Lock: $LOCK_DIR)" fi trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM check_remote if [ "$mode" = "check" ]; then check_document_exporter write_backup_info /dev/stdout log "Pruefung erfolgreich; temporaerer Testexport entfernt und kein Backup auf dem Mac gespeichert" exit 0 fi check_capacity /bin/mkdir -p "$LOCAL_CURRENT_DIR/paperless-export" "$LOCAL_CURRENT_DIR/truenas-app-config" remote_current_host="$REMOTE_EXPORT_HOST_DIR/macos-backup-current" log "Aktualisiere den dauerhaften Paperless-Export ..." if ! "${SSH[@]}" "$SERVER" \ "docker exec --user paperless:paperless '$PAPERLESS_CONTAINER' mkdir -p '$REMOTE_CURRENT_DIR' && docker exec '$PAPERLESS_CONTAINER' document_exporter --no-progress-bar --compare-json --delete --use-folder-prefix '$REMOTE_CURRENT_DIR'" \ 2>&1 | /usr/bin/tee -a "$LOG_FILE"; then die "document_exporter ist fehlgeschlagen" fi log "Synchronisiere neue und geaenderte Dokumente auf den Mac ..." /usr/bin/rsync -a --delete --no-owner --no-group -e "$RSYNC_RSH" \ "$SERVER:$remote_current_host/" "$LOCAL_CURRENT_DIR/paperless-export/" \ >> "$LOG_FILE" 2>&1 || die "Uebertragung des Paperless-Exports ist fehlgeschlagen" [ -s "$LOCAL_CURRENT_DIR/paperless-export/manifest.json" ] || \ die "Der lokale Export enthaelt kein gueltiges manifest.json" log "Sichere die TrueNAS-App-Konfiguration ..." /usr/bin/rsync -a --delete --no-owner --no-group -e "$RSYNC_RSH" \ "$SERVER:$APP_CONFIG_DIR/" "$LOCAL_CURRENT_DIR/truenas-app-config/" \ >> "$LOG_FILE" 2>&1 || die "Uebertragung der App-Konfiguration ist fehlgeschlagen" write_backup_info "$LOCAL_CURRENT_DIR/backup-info.txt" export_size=$(/usr/bin/du -sh "$LOCAL_CURRENT_DIR" | /usr/bin/awk '{print $1}') BACKUP_SUCCEEDED=1 log "Backup erfolgreich aktualisiert: $LOCAL_CURRENT_DIR ($export_size)" notify "Erfolgreich aktualisiert" "$LOCAL_CURRENT_DIR – $export_size" } main "$@"