diff --git a/01_setup.sh b/01_setup.sh new file mode 100644 index 0000000..f77b5c5 --- /dev/null +++ b/01_setup.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +APP="raspi-backup" +STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/${APP}" +LOG_FILE="${STATE_DIR}/${APP}.log" + +mkdir -p "$STATE_DIR" + +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE" >/dev/null +} + +write_config_if_missing() { + local cfg="$SCRIPT_DIR/config.sh" + + if [[ -f "$cfg" ]]; then + log "config.sh existiert: $cfg" + return 0 + fi + + cat >"$cfg" <<'EOF' +#!/usr/bin/env bash +# zentrale Konfig (auto-generated) – bitte nur ändern wenn du willst. + +# NAS Fixwerte +NAS_HOST="10.0.0.101" +NAS_USER="mexx" +NAS_PORT="222" + +# SSH / Alias +KEY_TYPE="ed25519" +ALIAS_PREFIX="nas" # Alias wird: nas- + +# lokaler User, dem der SSH-Key gehört (NICHT root) +SSH_USER="admin" + +# ---------- NAS Pfade (deine Vorgaben) ---------- +# Zugriffskontrolle (Keys): +NAS_AUTH_KEYS_FILE="/var/services/homes/mexx/raspberry/sshkeys/authorized_keys" + +# Backups (Daten): +NAS_BACKUP_BASE="/volume1/homes/mexx/raspberry/backup" +# Remote Ziel wird dann: ${NAS_BACKUP_BASE}// + +# Retention (Tage) +KEEP_DAYS="21" + +# Excludes +EXCLUDES=( + "/dev/*" + "/proc/*" + "/sys/*" + "/tmp/*" + "/run/*" + "/mnt/*" + "/media/*" + "/lost+found" + "/var/cache/apt/archives/*" +) + +# Rsync Extras +RSYNC_EXTRA=( + "--partial" + "--partial-dir=.rsync-partial" +) +EOF + + chmod +x "$cfg" || true + log "config.sh erzeugt: $cfg" +} + +write_config_if_missing +log "Setup OK (ohne lib.sh)." +echo "Setup OK." +echo "Config: $SCRIPT_DIR/config.sh" +echo "Log: $LOG_FILE" diff --git a/02_setup_ssh.sh b/02_setup_ssh.sh new file mode 100644 index 0000000..7a154cd --- /dev/null +++ b/02_setup_ssh.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +CFG="$SCRIPT_DIR/config.sh" +[[ -f "$CFG" ]] || { echo "ERROR: config.sh fehlt: $CFG (erst 01_setup.sh ausführen)"; exit 1; } +# shellcheck disable=SC1090 +source "$CFG" + +# --- helpers --- +APP="raspi-backup" +STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/${APP}" +LOG_FILE="${STATE_DIR}/${APP}.log" +mkdir -p "$STATE_DIR" + +log(){ echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE" >/dev/null; } +die(){ log "ERROR: $*"; echo "ERROR: $*" >&2; exit 1; } +need_cmd(){ command -v "$1" >/dev/null 2>&1 || die "Fehlt: $1"; } +need_root(){ [[ "${EUID:-$(id -u)}" -eq 0 ]] || die "Bitte mit sudo starten: sudo $SCRIPT_DIR/02_setup_ssh.sh"; } +host_short(){ hostname -s 2>/dev/null || hostname 2>/dev/null || echo "raspi"; } +nas_alias(){ echo "${ALIAS_PREFIX}-$(host_short)"; } + +need_root +need_cmd ssh +need_cmd ssh-keygen + +# validate config vars +: "${NAS_HOST:?}" "${NAS_USER:?}" "${NAS_PORT:?}" "${KEY_TYPE:?}" "${ALIAS_PREFIX:?}" "${SSH_USER:?}" "${NAS_AUTH_KEYS_FILE:?}" + +hn="$(host_short)" +alias="$(nas_alias)" +ssh_user="$SSH_USER" + +user_home="$(eval echo "~${ssh_user}")" +[[ -d "$user_home" ]] || die "Home für SSH_USER '$ssh_user' nicht gefunden." + +ssh_dir="${user_home}/.ssh" +key="${ssh_dir}/id_${KEY_TYPE}_${hn}" +pub="${key}.pub" +cfg="${ssh_dir}/config" + +log "SSH Setup START: ssh_user=${ssh_user} alias=${alias} nas=${NAS_USER}@${NAS_HOST}:${NAS_PORT} key=${key}" +log "NAS_AUTH_KEYS_FILE: ${NAS_AUTH_KEYS_FILE}" + +# .ssh anlegen +sudo -u "$ssh_user" mkdir -p "$ssh_dir" +sudo -u "$ssh_user" chmod 700 "$ssh_dir" +sudo -u "$ssh_user" touch "$cfg" +sudo -u "$ssh_user" chmod 600 "$cfg" + +# Key anlegen falls fehlt +if [[ ! -f "$key" ]]; then + log "Erzeuge Key: $key" + sudo -u "$ssh_user" ssh-keygen -t "$KEY_TYPE" -a 64 -f "$key" -N "" -C "${ssh_user}@${hn}" +else + log "Key existiert: $key" +fi + +# Alias Block append-only +if ! sudo -u "$ssh_user" grep -qE "^Host[[:space:]]+${alias}$" "$cfg"; then + log "Füge Alias Block hinzu: $alias" + sudo -u "$ssh_user" tee -a "$cfg" >/dev/null </dev/null \ + || die "SSH Verbindung fehlgeschlagen: ${NAS_HOST}:${NAS_PORT}" + +# Pubkey lesen +[[ -f "$pub" ]] || die "Public key fehlt: $pub" +pubkey="$(sudo -u "$ssh_user" cat "$pub")" + +# WICHTIG: Key in zentrale NAS-Datei schreiben (nicht ~/.ssh/authorized_keys) +# - legt Verzeichnis an +# - sorgt für Datei + Rechte +# - fügt Key nur hinzu, wenn noch nicht vorhanden +log "Installiere Public Key in zentrale Datei am NAS..." +sudo -u "$ssh_user" ssh -p "$NAS_PORT" -o ConnectTimeout=10 \ + "${NAS_USER}@${NAS_HOST}" \ + "set -e; + f='${NAS_AUTH_KEYS_FILE}'; + d=\$(dirname \"\$f\"); + mkdir -p \"\$d\"; + touch \"\$f\"; + chmod 600 \"\$f\"; + grep -qxF '$pubkey' \"\$f\" || echo '$pubkey' >> \"\$f\"" + +log "SSH Setup OK." +echo "SSH Setup OK." +echo "Test:" +echo " sudo -u ${ssh_user} ssh ${alias} 'echo hello'" diff --git a/03_verify.sh b/03_verify.sh new file mode 100644 index 0000000..f9f7dbc --- /dev/null +++ b/03_verify.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +CFG="$SCRIPT_DIR/config.sh" +[[ -f "$CFG" ]] || { echo "ERROR: config.sh fehlt: $CFG (erst 01_setup.sh ausführen)"; exit 1; } +# shellcheck disable=SC1090 +source "$CFG" + +# --- helpers --- +APP="raspi-backup" +STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/${APP}" +LOG_FILE="${STATE_DIR}/${APP}.log" +mkdir -p "$STATE_DIR" + +log(){ echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE" >/dev/null; } +die(){ log "ERROR: $*"; echo "ERROR: $*" >&2; exit 1; } +need_cmd(){ command -v "$1" >/dev/null 2>&1 || die "Fehlt: $1"; } +need_root(){ [[ "${EUID:-$(id -u)}" -eq 0 ]] || die "Bitte mit sudo starten: sudo $SCRIPT_DIR/03_verify.sh"; } +host_short(){ hostname -s 2>/dev/null || hostname 2>/dev/null || echo "raspi"; } +nas_alias(){ echo "${ALIAS_PREFIX}-$(host_short)"; } + +need_root +need_cmd ssh +need_cmd rsync + +# validate config vars +: "${NAS_HOST:?}" "${NAS_USER:?}" "${NAS_PORT:?}" "${ALIAS_PREFIX:?}" "${SSH_USER:?}" "${NAS_BACKUP_BASE:?}" + +hn="$(host_short)" +alias="$(nas_alias)" +ssh_user="$SSH_USER" +remote_root="${NAS_BACKUP_BASE%/}/${hn}" + +log "VERIFY START: alias=${alias} remote_root=${remote_root}" + +# optional Port check +if command -v nc >/dev/null 2>&1; then + log "Check NAS Port: ${NAS_HOST}:${NAS_PORT}" + nc -vz "$NAS_HOST" "$NAS_PORT" >/dev/null 2>&1 || die "NAS Port nicht erreichbar: ${NAS_HOST}:${NAS_PORT}" +else + log "nc nicht vorhanden – überspringe Port-Check" +fi + +# SSH login via alias +log "Check SSH Login: ${alias}" +sudo -u "$ssh_user" ssh -o BatchMode=yes -o ConnectTimeout=10 "$alias" "echo ok" >/dev/null \ + || die "SSH Login über Alias fehlgeschlagen: $alias" + +# Remote backup dir +log "Check/Create Remote Backup Dir: ${remote_root}" +sudo -u "$ssh_user" ssh -o BatchMode=yes "$alias" "mkdir -p '$remote_root' && test -d '$remote_root'" >/dev/null \ + || die "Remote Backup Pfad nicht nutzbar: $remote_root" + +log "VERIFY OK" +echo "Verify OK:" +echo "- NAS erreichbar (${NAS_HOST}:${NAS_PORT})" +echo "- SSH Alias funktioniert (${alias})" +echo "- Remote Backup Pfad OK (${remote_root})" diff --git a/04_run_backup.sh b/04_run_backup.sh new file mode 100644 index 0000000..af5001c --- /dev/null +++ b/04_run_backup.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +CFG="$SCRIPT_DIR/config.sh" +[[ -f "$CFG" ]] || { echo "ERROR: config.sh fehlt: $CFG (erst 01_setup.sh ausführen)"; exit 1; } +# shellcheck disable=SC1090 +source "$CFG" + +# --- helpers --- +LOG_FILE="$SCRIPT_DIR/raspi-backup.log" +LOCK_FILE="$SCRIPT_DIR/.raspi-backup.lock" + +ts_now() { date '+%Y-%m-%d %H:%M:%S'; } +log() { echo "[$(ts_now)] $*" | tee -a "$LOG_FILE" >/dev/null; } +die() { log "ERROR: $*"; echo "ERROR: $*" >&2; exit 1; } +need_cmd(){ command -v "$1" >/dev/null 2>&1 || die "Fehlt: $1"; } +need_root(){ [[ "${EUID:-$(id -u)}" -eq 0 ]] || die "Bitte mit sudo starten: sudo $SCRIPT_DIR/04_run_backup.sh"; } +host_short(){ hostname -s 2>/dev/null || hostname 2>/dev/null || echo "raspi"; } + +acquire_lock() { + if command -v flock >/dev/null 2>&1; then + exec 9>"$LOCK_FILE" + flock -n 9 || die "Schon ein Lauf aktiv (Lock: $LOCK_FILE)" + else + [[ -e "$LOCK_FILE" ]] && die "Schon ein Lauf aktiv (Lock: $LOCK_FILE)" + echo "$$" >"$LOCK_FILE" + trap 'rm -f "$LOCK_FILE" >/dev/null 2>&1 || true' EXIT + fi +} + +run_logged() { + "$@" 2>&1 | tee -a "$LOG_FILE" +} + +need_root +need_cmd rsync +need_cmd ssh +need_cmd tee + +# validate config vars +: "${NAS_HOST:?}" "${NAS_USER:?}" "${NAS_PORT:?}" "${SSH_USER:?}" "${NAS_BACKUP_BASE:?}" "${KEEP_DAYS:?}" "${KEY_TYPE:?}" + +hn="$(host_short)" +ssh_user="$SSH_USER" + +# Keypfad: gehört SSH_USER +key_path="$(eval echo "~${ssh_user}/.ssh/id_${KEY_TYPE}_${hn}")" +[[ -f "$key_path" ]] || die "Key fehlt: $key_path (erst 02_setup_ssh.sh ausführen)" + +run_ts="$(date '+%Y-%m-%d_%H-%M')" +remote_root="${NAS_BACKUP_BASE%/}/${hn}" +remote_dest="${remote_root}/${run_ts}" + +log "============================================================" +log "BACKUP START" +log "Host : ${hn}" +log "NAS : ${NAS_USER}@${NAS_HOST}:${NAS_PORT}" +log "Key (local): ${key_path}" +log "Remote root: ${remote_root}" +log "Remote dest: ${remote_dest}" +log "Keep days : ${KEEP_DAYS}" +log "Logfile : ${LOG_FILE}" +log "============================================================" + +acquire_lock + +start_epoch="$(date +%s)" + +# Common SSH options (no alias dependency) +SSH_BASE=(ssh -p "$NAS_PORT" -i "$key_path" + -o IdentitiesOnly=yes + -o BatchMode=yes + -o StrictHostKeyChecking=yes + -o ConnectTimeout=20 +) + +log "Phase 1/5: Remote Ordner anlegen..." +run_logged sudo -u "$ssh_user" "${SSH_BASE[@]}" "${NAS_USER}@${NAS_HOST}" \ + "mkdir -p '$remote_dest' '$remote_root'" \ + || die "Remote Ordner konnte nicht erstellt werden" + +# Build rsync excludes/extras +exclude_args=() +if declare -p EXCLUDES >/dev/null 2>&1; then + for e in "${EXCLUDES[@]}"; do exclude_args+=( "--exclude=$e" ); done +fi + +extra_args=() +if declare -p RSYNC_EXTRA >/dev/null 2>&1; then + for o in "${RSYNC_EXTRA[@]}"; do extra_args+=( "$o" ); done +fi + +ssh_cmd="ssh -p ${NAS_PORT} -i ${key_path} -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes -o ConnectTimeout=20" + +log "Phase 2/5: rsync START (Live-Progress sichtbar)..." +log "Hinweis: Das kann je nach Datenmenge sehr lange dauern." +# WICHTIG: --omit-dir-times verhindert, dass der frische Backup-Ordner durch alte mtime sofort von Retention gelöscht wird +rsync -aAXH --numeric-ids \ + --omit-dir-times \ + --info=progress2,stats2 \ + "${exclude_args[@]}" \ + "${extra_args[@]}" \ + -e "$ssh_cmd" \ + / \ + "${NAS_USER}@${NAS_HOST}:${remote_dest}/" \ + 2>&1 | tee -a "$LOG_FILE" \ + || die "rsync fehlgeschlagen" + +log "Phase 3/5: mtime sichern (touch), damit Retention den frischen Lauf nicht killt..." +run_logged sudo -u "$ssh_user" "${SSH_BASE[@]}" "${NAS_USER}@${NAS_HOST}" \ + "touch '$remote_dest' '$remote_root'" \ + || die "touch auf NAS fehlgeschlagen" + +log "Phase 4/5: latest Symlink setzen..." +run_logged sudo -u "$ssh_user" "${SSH_BASE[@]}" "${NAS_USER}@${NAS_HOST}" \ + "cd '$remote_root' && ln -sfn '${run_ts}' latest" \ + || die "latest Symlink fehlgeschlagen" + +log "Phase 5/5: Retention (> ${KEEP_DAYS} Tage) aufräumen..." +run_logged sudo -u "$ssh_user" "${SSH_BASE[@]}" "${NAS_USER}@${NAS_HOST}" \ + "find '$remote_root' -maxdepth 1 -type d -name '20??-??-??_??-??' -mtime +${KEEP_DAYS} -print -exec rm -rf {} \;" \ + || die "Retention Cleanup fehlgeschlagen" + +end_epoch="$(date +%s)" +dur_sec="$((end_epoch - start_epoch))" +dur_min="$((dur_sec / 60))" +dur_rem="$((dur_sec % 60))" + +log "============================================================" +log "BACKUP OK" +log "Dauer: ${dur_min}m ${dur_rem}s" +log "Ziel : ${NAS_HOST}:${remote_dest}" +log "============================================================" + +echo +echo "Backup fertig → ${NAS_HOST}:${remote_dest}" +echo "Dauer: ${dur_min}m ${dur_rem}s" +echo "Log: $LOG_FILE" diff --git a/05_status.sh b/05_status.sh new file mode 100644 index 0000000..6849e4f --- /dev/null +++ b/05_status.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +CFG="$SCRIPT_DIR/config.sh" +[[ -f "$CFG" ]] || { echo "ERROR: config.sh fehlt: $CFG (erst 01_setup.sh ausführen)"; exit 1; } +# shellcheck disable=SC1090 +source "$CFG" + +LOG_FILE="$SCRIPT_DIR/raspi-backup.log" + +host_short(){ hostname -s 2>/dev/null || hostname 2>/dev/null || echo "raspi"; } +nas_alias(){ echo "${ALIAS_PREFIX}-$(host_short)"; } + +hn="$(host_short)" +alias="$(nas_alias)" +remote_root="${NAS_BACKUP_BASE%/}/${hn}" + +echo "== Backup Status ==" +echo "Script-Ordner : $SCRIPT_DIR" +echo "Host : $hn" +echo "NAS : ${NAS_USER}@${NAS_HOST}:${NAS_PORT}" +echo "Remote Root : $remote_root" +echo "Log : $LOG_FILE" +echo + +# Log-Info (letzter Lauf) +if [[ -f "$LOG_FILE" ]]; then + echo "-- Letzter Log-Block (kurz) --" + # Zeige die letzten ~25 Zeilen, aber etwas "smart" ab BACKUP START wenn vorhanden + if grep -q "BACKUP START" "$LOG_FILE"; then + awk ' + BEGIN{start=0} + /BACKUP START/{start=1} + {buf[NR]=$0} + END{ + # gib die letzten 80 Zeilen aus, aber nur wenn start irgendwann vorkam + from = (NR-80>1)?NR-80:1 + for(i=from;i<=NR;i++) print buf[i] + }' "$LOG_FILE" | tail -n 25 + else + tail -n 25 "$LOG_FILE" + fi + echo +else + echo "Noch kein Log vorhanden." + echo +fi + +# NAS Status (ohne Passwort möglich nur wenn Key-Auth passt) +echo "-- NAS Check --" +echo "Alias (nur Info): $alias" +echo "Hinweis: Dieser Status nutzt direkten SSH Host/Port/User. Falls Passwortabfrage kommt, ist Key-Auth noch nicht sauber." +echo + +# Keypfad für SSH_USER +hn2="$hn" +key_path="$(eval echo "~${SSH_USER}/.ssh/id_${KEY_TYPE}_${hn2}")" + +if [[ ! -f "$key_path" ]]; then + echo "Local Key fehlt: $key_path" + echo "=> erst 02_setup_ssh.sh ausführen." + exit 0 +fi + +SSH_BASE=(ssh -p "$NAS_PORT" -i "$key_path" + -o IdentitiesOnly=yes + -o BatchMode=yes + -o StrictHostKeyChecking=yes + -o ConnectTimeout=10 +) + +# Remote listing +if sudo -u "$SSH_USER" "${SSH_BASE[@]}" "${NAS_USER}@${NAS_HOST}" "test -d '$remote_root'" >/dev/null 2>&1; then + echo "Remote erreichbar: OK" + echo + + echo "-- Remote Inhalt --" + sudo -u "$SSH_USER" "${SSH_BASE[@]}" "${NAS_USER}@${NAS_HOST}" "ls -la '$remote_root' | sed -n '1,40p'" + echo + + echo "-- latest Ziel + Größe (wenn vorhanden) --" + sudo -u "$SSH_USER" "${SSH_BASE[@]}" "${NAS_USER}@${NAS_HOST}" \ + "if [ -L '$remote_root/latest' ]; then \ + echo -n 'latest -> '; readlink '$remote_root/latest' || true; \ + echo -n 'Size(latest): '; du -sh '$remote_root/latest' 2>/dev/null || echo 'n/a'; \ + else \ + echo 'Kein latest Symlink vorhanden.'; \ + fi" +else + echo "Remote erreichbar: FAIL" + echo "Check:" + echo " - NAS erreichbar? IP/Port" + echo " - SSH Key installiert?" + echo " - Rechte auf $remote_root" +fi