diff --git a/macos/backup-paperless-ngx/README.md b/macos/backup-paperless-ngx/README.md new file mode 100644 index 0000000..be8d5f5 --- /dev/null +++ b/macos/backup-paperless-ngx/README.md @@ -0,0 +1,96 @@ +# Paperless-ngx-Backup von TrueNAS auf macOS + +Das Script ist fuer die auf `bfds` erkannte Installation vorbereitet: + +- TrueNAS 25.04.2.6 +- TrueNAS-App `paperless-ngx` 1.6.37 +- Paperless-ngx 3.0.2 +- Container `ix-paperless-ngx-paperless-1` + +Es pflegt einen dauerhaften Paperless-Export und synchronisiert ihn ungezippt +auf den Mac. Nach dem ersten vollstaendigen Lauf werden nur neue, geaenderte +oder geloeschte Dateien abgeglichen. Es entstehen keine Archive und keine +taeglichen Vollkopien. + +Der lokale Stand befindet sich hier: + +```text +~/Backups/paperless-ngx/current/ +├── backup-info.txt +├── paperless-export/ +│ ├── manifest.json +│ ├── metadata.json +│ ├── archive/ +│ ├── originals/ +│ └── thumbnails/ +└── truenas-app-config/ +``` + +`paperless-export` enthaelt Dokumente, Thumbnails, Metadaten, Benutzer und die +exportierten Datenbankinhalte. `truenas-app-config` enthaelt zusaetzlich die +TrueNAS-App-Einstellungen und die Konfiguration frueherer App-Versionen. + +## Testen und ausfuehren + +```bash +./backup-paperless-ngx.sh --check +./backup-paperless-ngx.sh +``` + +`--check` prueft SSH, Container und Mounts. Ausserdem wird ein kleiner +temporaerer `document_exporter --data-only`-Lauf ausgefuehrt und dessen +Manifest kontrolliert. Der Testexport wird sofort wieder entfernt. + +Alle Werte lassen sich ueber `~/.config/paperless-backup/config` aendern; +siehe `config.example`. Voraussetzung ist, dass +`ssh -o BatchMode=yes bfds true` ohne Passwortdialog funktioniert. + +## Taeglich um 03:00 Uhr ausfuehren + +```bash +./install-launchagent.sh +``` + +Der Installer kopiert das Script nach +`~/Library/Application Support/PaperlessBackup`, legt bei Bedarf die +Konfiguration an und registriert einen LaunchAgent. Ein manueller Lauf des +installierten Jobs ist mit folgendem Befehl moeglich: + +```bash +launchctl kickstart -k gui/$(id -u)/de.rob.paperless-ngx-backup +``` + +Logs stehen in `~/Library/Logs/paperless-ngx-backup.log` sowie in den beiden +`paperless-ngx-launchd.*.log`-Dateien. + +## Wiederherstellung + +1. Eine frische Paperless-Instanz mit derselben Paperless-Version wie in + `backup-info.txt` bereitstellen. +2. Den Inhalt von `current/paperless-export` in das Export-Volume der neuen + Instanz kopieren und dort `document_importer` ausfuehren. +3. `current/truenas-app-config` dient als Referenz fuer Ports, Storage, + Umgebungswerte und Secrets. Diese Dateien nicht blind in eine laufende + TrueNAS-App zurueckkopieren. + +Der Paperless-Exporter sichert keine API-Tokens; sie muessen nach einem Import +neu erzeugt werden. + +## Verhalten und Sicherheit + +Das ist ein Spiegel des aktuellen Paperless-Stands. Wird ein Dokument in +Paperless geloescht, wird es beim naechsten erfolgreichen Lauf auch aus diesem +Export entfernt. Fuer historische Staende sollte der Ordner zusaetzlich durch +Time Machine oder ein anderes versionierendes Backup gesichert werden. + +Der Export enthaelt alle Dokumente und moegliche Klartext-Secrets aus der +TrueNAS-App-Konfiguration. Das uebergeordnete Zielverzeichnis ist nur fuer den +aktuellen macOS-Benutzer zugaenglich. FileVault sollte aktiviert sein. + +Auf dem TrueNAS-Apps-Pool bleibt ebenfalls ein ungezipptes Exportverzeichnis +`macos-backup-current` bestehen. Es wird von Paperless bei jedem Lauf +inkrementell aktualisiert. + +Bereits mit einer aelteren Scriptversion erzeugte `.tar.gz`-Archive werden +nicht automatisch geloescht. Sie koennen nach erfolgreicher Kontrolle des +neuen `current`-Ordners manuell entfernt werden. diff --git a/macos/backup-paperless-ngx/backup-paperless-ngx.sh b/macos/backup-paperless-ngx/backup-paperless-ngx.sh new file mode 100755 index 0000000..b3c57dc --- /dev/null +++ b/macos/backup-paperless-ngx/backup-paperless-ngx.sh @@ -0,0 +1,290 @@ +#!/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 "$@" diff --git a/macos/backup-paperless-ngx/config.example b/macos/backup-paperless-ngx/config.example new file mode 100644 index 0000000..458a143 --- /dev/null +++ b/macos/backup-paperless-ngx/config.example @@ -0,0 +1,12 @@ +# Diese Datei nach ~/.config/paperless-backup/config kopieren und anpassen. +# Die Standardwerte passen bereits zur untersuchten Installation auf "bfds". + +SERVER="bfds" +APP_NAME="paperless-ngx" +PAPERLESS_CONTAINER="ix-paperless-ngx-paperless-1" +BACKUP_DIR="$HOME/Backups/paperless-ngx" + +# 1 = macOS-Mitteilung bei Erfolg/Fehler, 0 = keine Mitteilung. +NOTIFY=1 + +SSH_CONNECT_TIMEOUT=10 diff --git a/macos/backup-paperless-ngx/install-launchagent.sh b/macos/backup-paperless-ngx/install-launchagent.sh new file mode 100755 index 0000000..35205d6 --- /dev/null +++ b/macos/backup-paperless-ngx/install-launchagent.sh @@ -0,0 +1,57 @@ +#!/bin/bash + +set -euo pipefail + +SOURCE_DIR=$(cd "$(dirname "$0")" && pwd) +INSTALL_DIR="$HOME/Library/Application Support/PaperlessBackup" +CONFIG_DIR="$HOME/.config/paperless-backup" +PLIST="$HOME/Library/LaunchAgents/de.rob.paperless-ngx-backup.plist" +LOG_DIR="$HOME/Library/Logs" +UID_VALUE=$(id -u) + +mkdir -p "$INSTALL_DIR" "$CONFIG_DIR" "$(dirname "$PLIST")" "$LOG_DIR" +install -m 700 "$SOURCE_DIR/backup-paperless-ngx.sh" "$INSTALL_DIR/backup-paperless-ngx.sh" + +if [ ! -f "$CONFIG_DIR/config" ]; then + install -m 600 "$SOURCE_DIR/config.example" "$CONFIG_DIR/config" + echo "Konfiguration angelegt: $CONFIG_DIR/config" +else + echo "Vorhandene Konfiguration bleibt unveraendert: $CONFIG_DIR/config" +fi + +cat > "$PLIST" < + + + + Label + de.rob.paperless-ngx-backup + ProgramArguments + + $INSTALL_DIR/backup-paperless-ngx.sh + + StartCalendarInterval + + Hour + 3 + Minute + 0 + + ProcessType + Background + StandardOutPath + $LOG_DIR/paperless-ngx-launchd.out.log + StandardErrorPath + $LOG_DIR/paperless-ngx-launchd.err.log + + +EOF + +chmod 600 "$PLIST" +plutil -lint "$PLIST" +launchctl bootout "gui/$UID_VALUE" "$PLIST" >/dev/null 2>&1 || true +launchctl bootstrap "gui/$UID_VALUE" "$PLIST" + +echo "LaunchAgent installiert: taeglich um 03:00 Uhr" +echo "Vor dem ersten echten Lauf bitte pruefen:" +echo " $INSTALL_DIR/backup-paperless-ngx.sh --check"