Merge pull request #51 from outis1one/claude/peaceful-goodall-nIfh9

Claude/peaceful goodall n ifh9
This commit is contained in:
Outis
2026-06-04 15:44:02 -04:00
committed by GitHub
9 changed files with 1067 additions and 301 deletions
+1 -1
View File
@@ -72,7 +72,7 @@ Update them any time with `sudo ./setup.sh configure`.
| `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` |
| `gaming` | `js99er`, `minecraft`, `wolf`, `wolf-pair` |
| `extras` | `silent-send`, `sync-cc` |
| `backup` | `backup` — complete recovery: entire `~/docker/<service>/` for every service (Minecraft: flush+snap, no downtime; others: stop/snap/start for DB consistency); `gaming-backup` — frequent game-save snapshots (Minecraft world data, emulator saves, Steam — no downtime, run hourly) |
| `backup` | `backup` — complete recovery: entire `~/docker/<service>/` for every service via Kopia (Minecraft: flush+snap, no downtime; others: stop/snap/start for DB consistency); `borg-backup` — same coverage via Borg (chunk dedup, SSH remote repos, Borgmatic/Vorta compatible); `gaming-backup` — frequent game-save snapshots (Minecraft world data, emulator saves, Steam — no downtime, run hourly) |
Run `./setup.sh --list` to see descriptions.
+133
View File
@@ -0,0 +1,133 @@
#!/bin/bash
# extras/backup_borg.sh — Borg backup worker for all Docker services.
# Installed to ~/docker/borg-backup/backup_borg.sh by the borg-backup installer.
#
# sudo ./backup_borg.sh run a full backup cycle
# sudo ./backup_borg.sh list list all archives in all repos
# sudo ./backup_borg.sh info show repo info for all destinations
#
# Minecraft instances: flush to disk (save-all) then archive — no downtime.
# All other services: stop → archive → restart for consistency.
# Reads backup.conf from the same directory.
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONF="${BACKUP_CONF:-$HERE/backup.conf}"
[ -f "$CONF" ] || { echo "Config not found: $CONF (re-run: sudo setup.sh borg-backup)"; exit 1; }
# shellcheck source=/dev/null
source "$CONF"
ACTUAL_USER="${SUDO_USER:-${USER:-$(id -un)}}"
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "/home/$ACTUAL_USER")"
DOCKER_DIR="$ACTUAL_HOME/docker"
log() { echo "[$(date '+%F %T')] $*"; }
repo_for() { local var="DEST_${1}_REPO"; echo "${!var:-}"; }
pass_for() { local var="DEST_${1}_PASSPHRASE"; echo "${!var:-}"; }
dest_for_svc() { local var="SVC_${1//-/_}"; echo "${!var:-${DEST_DEFAULT:-default}}"; }
b_for() {
local dest="$1"; shift
local repo; repo="$(repo_for "$dest")"
local pass; pass="$(pass_for "$dest")"
[ -n "$repo" ] || { log "Unknown destination: $dest"; return 1; }
BORG_PASSPHRASE="$pass" BORG_REPO="$repo" "$BORG" "$@"
}
is_minecraft() { [ -f "${1}Dockerfile" ] && grep -qs itzg "${1}Dockerfile"; }
case "${1:-run}" in
list)
for dest in ${DEST_NAMES:-default}; do
echo ""; echo "── dest: $dest ($(repo_for "$dest")) ──"
b_for "$dest" list 2>/dev/null | sort -r | head -30 || true
done
exit 0 ;;
info)
for dest in ${DEST_NAMES:-default}; do
echo ""; echo "── dest: $dest ──"
b_for "$dest" info 2>/dev/null || true
done
exit 0 ;;
esac
log "===== Borg backup starting ====="
rc=0
TS="$(date +%Y-%m-%dT%H-%M-%S)"
for svc_dir in "$DOCKER_DIR"/*/; do
[ -f "${svc_dir}docker-compose.yml" ] || continue
svc="$(basename "$svc_dir")"
[[ "$svc" == "borg-backup" || "$svc" == "backup" || "$svc" == "gaming-backup" ]] && continue
dest="$(dest_for_svc "$svc")"
repo="$(repo_for "$dest")"
[ -n "$repo" ] || { log "SKIP $svc — dest '$dest' not configured"; continue; }
ARCHIVE="${svc}-${TS}"
if is_minecraft "$svc_dir"; then
if docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "$svc"; then
log "Flushing Minecraft world '$svc' (save-all, no downtime)..."
docker exec "$svc" mc-send-to-console save-all flush 2>/dev/null \
|| docker exec "$svc" rcon-cli save-all 2>/dev/null || true
sleep 5
fi
log "Archiving $svc$dest::$ARCHIVE ..."
if b_for "$dest" create \
--compression=zstd,6 --exclude-caches --stats \
"::$ARCHIVE" "$svc_dir" 2>&1 | while IFS= read -r line; do log " $line"; done; then
log "OK $svc (Minecraft, no downtime)"
else
log "WARNING: archive failed for $svc"; rc=1
fi
else
STOPPED=false
if docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "$svc"; then
log "Stopping $svc..."
docker compose -f "${svc_dir}docker-compose.yml" down 2>/dev/null \
|| docker stop "$svc" 2>/dev/null \
|| log "WARNING: could not stop $svc — archiving live (consistency not guaranteed)"
STOPPED=true
fi
log "Archiving $svc$dest::$ARCHIVE ..."
if b_for "$dest" create \
--compression=zstd,6 --exclude-caches --stats \
"::$ARCHIVE" "$svc_dir" 2>&1 | while IFS= read -r line; do log " $line"; done; then
log "OK $svc"
else
log "WARNING: archive failed for $svc"; rc=1
fi
if [ "$STOPPED" = true ]; then
log "Starting $svc..."
docker compose -f "${svc_dir}docker-compose.yml" up -d 2>/dev/null \
|| log "WARNING: could not restart $svc — run: docker compose -f ${svc_dir}docker-compose.yml up -d"
fi
fi
log "Pruning old archives for $svc in '$dest'..."
b_for "$dest" prune \
--keep-daily="${KEEP_DAILY:-7}" \
--keep-weekly="${KEEP_WEEKLY:-4}" \
--keep-monthly="${KEEP_MONTHLY:-3}" \
--glob-archives="${svc}-*" \
--list 2>/dev/null \
|| log "WARNING: prune failed for $svc (non-fatal)"
done
for dest in ${DEST_NAMES:-default}; do
repo="$(repo_for "$dest")"
[ -n "$repo" ] || continue
log "Compacting repo '$dest'..."
b_for "$dest" compact 2>/dev/null || true
done
if [ "$rc" -eq 0 ]; then
log "===== Borg backup complete ====="
else
log "===== Borg backup finished WITH WARNINGS (see above) ====="
fi
exit "$rc"
+88
View File
@@ -0,0 +1,88 @@
#!/bin/bash
# extras/backup_gaming.sh — Kopia gaming-saves worker (no service downtime).
# Installed to ~/docker/gaming-backup/backup_gaming.sh by the gaming-backup installer.
#
# sudo ./backup_gaming.sh run a backup now
# sudo ./backup_gaming.sh snapshots list snapshots
# sudo ./backup_gaming.sh policy show retention/ignore policy
#
# Nothing is stopped: Minecraft worlds are flushed to disk (save-all) first.
# Reads backup.conf from the same directory.
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONF="${BACKUP_CONF:-$HERE/backup.conf}"
[ -f "$CONF" ] || { echo "Config not found: $CONF (re-run: sudo setup.sh gaming-backup)"; exit 1; }
# shellcheck source=/dev/null
source "$CONF"
export KOPIA_PASSWORD
log() { echo "[$(date '+%F %T')] $*"; }
k() { "$KOPIA" --config-file="$KOPIA_CONFIG" "$@"; }
if ! k repository status >/dev/null 2>&1; then
log "ERROR: not connected to a repository — re-run the gaming-backup service"
exit 1
fi
case "${1:-run}" in
snapshots) k snapshot list; exit 0 ;;
policy) k policy show --global; exit 0 ;;
esac
log "===== Gaming backup starting ====="
if [ -n "${MC_BASE_DIR:-}" ] && command -v docker >/dev/null 2>&1; then
_flushed=0
for d in "$MC_BASE_DIR"/*/; do
[ -f "${d}Dockerfile" ] && grep -qs itzg "${d}Dockerfile" || continue
name="$(basename "$d")"
if docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "$name"; then
log "Flushing Minecraft world '$name' (save-all)..."
docker exec "$name" mc-send-to-console save-all flush 2>/dev/null \
|| docker exec "$name" rcon-cli save-all 2>/dev/null || true
_flushed=1
fi
done
[ "$_flushed" = 1 ] && sleep 5
fi
rc=0
snap() {
local label="$1" path="$2"
if [ -z "$path" ] || [ ! -e "$path" ]; then
log "skip $label — not found: ${path:-<unset>}"; return
fi
log "Snapshotting $label: $path"
if ! k snapshot create --description="gaming: $label" "$path"; then
log "WARNING: snapshot failed for $label"; rc=1
fi
}
if [ -n "${MC_BASE_DIR:-}" ]; then
for d in "$MC_BASE_DIR"/*/; do
[ -f "${d}Dockerfile" ] && grep -qs itzg "${d}Dockerfile" && [ -d "${d}data" ] || continue
nm="$(basename "$d")"
case "$nm" in minecraft*) lbl="$nm" ;; *) lbl="minecraft-$nm" ;; esac
snap "$lbl" "${d}data"
done
fi
[ "${BACKUP_SAVES:-no}" = yes ] && snap "emulator-saves" "$GAME_STORAGE_DIR/saves"
[ "${BACKUP_STEAM:-no}" = yes ] && snap "steam-userdata" "$GAME_STORAGE_DIR/steam"
[ "${BACKUP_MEDIA:-no}" = yes ] && snap "es-de-media" "$GAME_STORAGE_DIR/media"
[ "${BACKUP_WOLF:-no}" = yes ] && snap "wolf-state" "$WOLF_STATE_DIR"
if [ "${REMOTE_TYPE:-none}" != "none" ] && [ -n "${REMOTE_TYPE:-}" ]; then
log "Mirroring repository to remote ($REMOTE_TYPE)..."
# shellcheck disable=SC2086
if ! k repository sync-to "$REMOTE_TYPE" $REMOTE_ARGS; then
log "WARNING: remote mirror failed"; rc=1
fi
fi
if [ "$rc" -eq 0 ]; then
log "===== Gaming backup complete ====="
else
log "===== Gaming backup finished WITH WARNINGS ====="
fi
exit "$rc"
+120
View File
@@ -0,0 +1,120 @@
#!/bin/bash
# extras/backup_kopia.sh — Kopia backup worker for all Docker services.
# Installed to ~/docker/backup/backup_kopia.sh by the backup service installer.
#
# sudo ./backup_kopia.sh run a full backup cycle
# sudo ./backup_kopia.sh snapshots list all snapshots (all repos)
# sudo ./backup_kopia.sh policy show retention policies
#
# Minecraft instances: flush to disk (save-all) then snapshot — no downtime.
# All other services: stop → snapshot → restart for consistency.
# Reads backup.conf from the same directory.
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONF="${BACKUP_CONF:-$HERE/backup.conf}"
[ -f "$CONF" ] || { echo "Config not found: $CONF (re-run: sudo setup.sh backup)"; exit 1; }
# shellcheck source=/dev/null
source "$CONF"
ACTUAL_USER="${SUDO_USER:-${USER:-$(id -un)}}"
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "/home/$ACTUAL_USER")"
DOCKER_DIR="$ACTUAL_HOME/docker"
log() { echo "[$(date '+%F %T')] $*"; }
kp_for() {
local dest="$1"; shift
local cfg_var="DEST_${dest}_CONFIG" pw_var="DEST_${dest}_PASSWORD"
local cfg="${!cfg_var:-}" pw="${!pw_var:-}"
[ -n "$cfg" ] || { log "Unknown destination: $dest"; return 1; }
env KOPIA_PASSWORD="$pw" "$KOPIA" --config-file="$cfg" "$@"
}
dest_for_svc() {
local var="SVC_${1//-/_}"
echo "${!var:-${DEST_DEFAULT:-default}}"
}
is_minecraft() { [ -f "${1}Dockerfile" ] && grep -qs itzg "${1}Dockerfile"; }
case "${1:-run}" in
snapshots)
for dest in ${DEST_NAMES:-default}; do
echo ""; echo "── dest: $dest ──"
kp_for "$dest" snapshot list 2>/dev/null || true
done
exit 0 ;;
policy)
for dest in ${DEST_NAMES:-default}; do
echo ""; echo "── dest: $dest ──"
kp_for "$dest" policy show --global 2>/dev/null || true
done
exit 0 ;;
esac
log "===== Backup starting ====="
rc=0
for svc_dir in "$DOCKER_DIR"/*/; do
[ -f "${svc_dir}docker-compose.yml" ] || continue
svc="$(basename "$svc_dir")"
[[ "$svc" == "backup" || "$svc" == "borg-backup" || "$svc" == "gaming-backup" ]] && continue
dest="$(dest_for_svc "$svc")"
_repo_var="DEST_${dest}_REPO"
[ -n "${!_repo_var:-}" ] || { log "SKIP $svc — dest '$dest' not configured in conf"; continue; }
if is_minecraft "$svc_dir"; then
if docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "$svc"; then
log "Flushing Minecraft world '$svc' (save-all, no downtime)..."
docker exec "$svc" mc-send-to-console save-all flush 2>/dev/null \
|| docker exec "$svc" rcon-cli save-all 2>/dev/null || true
sleep 5
fi
log "Snapshotting $svc (dest: $dest)..."
if kp_for "$dest" snapshot create --description="backup: $svc" "$svc_dir"; then
log "OK $svc (Minecraft, no downtime)"
else
log "WARNING: snapshot failed for $svc"; rc=1
fi
else
STOPPED=false
if docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "$svc"; then
log "Stopping $svc..."
docker compose -f "${svc_dir}docker-compose.yml" down 2>/dev/null \
|| docker stop "$svc" 2>/dev/null \
|| log "WARNING: could not stop $svc — snapshotting live (consistency not guaranteed)"
STOPPED=true
fi
log "Snapshotting $svc (dest: $dest)..."
if kp_for "$dest" snapshot create --description="backup: $svc" "$svc_dir"; then
log "OK $svc"
else
log "WARNING: snapshot failed for $svc"; rc=1
fi
if [ "$STOPPED" = true ]; then
log "Starting $svc..."
docker compose -f "${svc_dir}docker-compose.yml" up -d 2>/dev/null \
|| log "WARNING: could not restart $svc — run: docker compose -f ${svc_dir}docker-compose.yml up -d"
fi
fi
done
if [ "${REMOTE_TYPE:-none}" != "none" ] && [ -n "${REMOTE_TYPE:-}" ]; then
for dest in ${DEST_NAMES:-default}; do
log "Mirroring '$dest' offsite ($REMOTE_TYPE)..."
# shellcheck disable=SC2086
kp_for "$dest" repository sync-to "$REMOTE_TYPE" $REMOTE_ARGS \
|| { log "WARNING: mirror failed for '$dest'"; rc=1; }
done
fi
if [ "$rc" -eq 0 ]; then
log "===== Backup complete ====="
else
log "===== Backup finished WITH WARNINGS (see above) ====="
fi
exit "$rc"
+249
View File
@@ -0,0 +1,249 @@
#!/bin/bash
# extras/restore_borg.sh — interactive restore from a Borg archive.
# Installed to ~/docker/borg-backup/restore_borg.sh by the borg-backup installer.
#
# Run as root:
# sudo ./restore_borg.sh interactive
# sudo ./restore_borg.sh --list list all archives and exit
#
# Reads backup.conf from the same directory (chmod 600, root-only).
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONF="${BACKUP_CONF:-$HERE/backup.conf}"
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
info() { echo -e "${BLUE}[INFO]${NC} $*"; }
ok() { echo -e "${GREEN}[OK]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
err() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
die() { err "$*"; exit 1; }
# ── Preflight ─────────────────────────────────────────────────────────────────
[ "${EUID:-$(id -u)}" -eq 0 ] || die "Run as root: sudo $0"
[ -f "$CONF" ] || die "backup.conf not found: $CONF"
command -v borg >/dev/null 2>&1 || die "borg not found — install: sudo apt install borgbackup"
# shellcheck source=/dev/null
source "$CONF"
ACTUAL_USER="${SUDO_USER:-${USER:-$(id -un)}}"
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "/home/$ACTUAL_USER")"
DOCKER_BASE="$ACTUAL_HOME/docker"
# ── Destination picker (skipped when only one dest) ───────────────────────────
read -ra _DEST_ARR <<< "${DEST_NAMES:-default}"
if [ "${#_DEST_ARR[@]}" -gt 1 ]; then
echo ""
echo "Select backup destination:"
echo ""
for i in "${!_DEST_ARR[@]}"; do
_repo_var="DEST_${_DEST_ARR[$i]}_REPO"
printf " %d) %s (%s)\n" "$((i+1))" "${_DEST_ARR[$i]}" "${!_repo_var:-unknown}"
done
echo ""
read -rp "Destination [1-${#_DEST_ARR[@]}]: " _DEST_SEL
[[ "$_DEST_SEL" =~ ^[0-9]+$ ]] && [ "$_DEST_SEL" -ge 1 ] && [ "$_DEST_SEL" -le "${#_DEST_ARR[@]}" ] \
|| die "Invalid selection"
ACTIVE_DEST="${_DEST_ARR[$((_DEST_SEL-1))]}"
else
ACTIVE_DEST="${_DEST_ARR[0]}"
fi
_REPO_VAR="DEST_${ACTIVE_DEST}_REPO"
_PASS_VAR="DEST_${ACTIVE_DEST}_PASSPHRASE"
export BORG_REPO="${!_REPO_VAR:-}"
export BORG_PASSPHRASE="${!_PASS_VAR:-}"
[ -n "$BORG_REPO" ] || die "No BORG_REPO found for destination '$ACTIVE_DEST'"
b() { borg "$@"; }
b info 2>/dev/null | grep -q "Repository" \
|| die "Cannot connect to Borg repository at $BORG_REPO — check backup.conf."
# ── --list ────────────────────────────────────────────────────────────────────
if [ "${1:-}" = "--list" ]; then
echo ""
info "Archives in $BORG_REPO (dest: $ACTIVE_DEST):"
echo ""
b list 2>/dev/null | sort -t- -k2 -r
echo ""
exit 0
fi
# ── Interactive restore ───────────────────────────────────────────────────────
echo ""
echo "╔═══════════════════════════════════════════════════════╗"
echo "║ Borg Restore ║"
echo "╚═══════════════════════════════════════════════════════╝"
echo ""
[ "${#_DEST_ARR[@]}" -gt 1 ] && info "Using destination: $ACTIVE_DEST ($BORG_REPO)"
info "Loading archive list..."
ARCHIVE_LIST=$(b list --format '{archive}{NL}' 2>/dev/null)
[ -z "$ARCHIVE_LIST" ] && die "No archives found. Run a backup first: sudo $HERE/backup_borg.sh"
# Group archives by service prefix (everything before the timestamp)
mapfile -t SERVICES < <(echo "$ARCHIVE_LIST" | sed 's/-[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T.*//' | sort -u)
echo "Backed-up services:"
echo ""
for i in "${!SERVICES[@]}"; do
svc="${SERVICES[$i]}"
latest=$(echo "$ARCHIVE_LIST" | grep "^${svc}-" | sort | tail -1)
count=$(echo "$ARCHIVE_LIST" | grep -c "^${svc}-")
printf " %2d) %-28s latest: %s | %s archive(s)\n" \
"$((i+1))" "$svc" "${latest#${svc}-}" "$count"
done
echo ""
read -rp "Select service [1-${#SERVICES[@]}] or q to quit: " SEL
[[ "$SEL" =~ ^[qQ]$ ]] && echo "Cancelled." && exit 0
[[ "$SEL" =~ ^[0-9]+$ ]] && [ "$SEL" -ge 1 ] && [ "$SEL" -le "${#SERVICES[@]}" ] \
|| die "Invalid selection: $SEL"
SELECTED_SVC="${SERVICES[$((SEL-1))]}"
ok "Service: $SELECTED_SVC"
# ── Pick an archive ───────────────────────────────────────────────────────────
echo ""
echo "Available archives (most recent first):"
echo ""
mapfile -t ARCHIVES < <(echo "$ARCHIVE_LIST" | grep "^${SELECTED_SVC}-" | sort -r | head -20)
[ "${#ARCHIVES[@]}" -eq 0 ] && die "No archives found for $SELECTED_SVC."
for i in "${!ARCHIVES[@]}"; do
note=""; [ "$i" -eq 0 ] && note=" ← latest"
ts="${ARCHIVES[$i]#${SELECTED_SVC}-}"
printf " %2d) %s%s\n" "$((i+1))" "$ts" "$note"
done
echo ""
read -rp "Select archive [1-${#ARCHIVES[@]}, Enter = latest]: " ARCH_SEL
ARCH_SEL="${ARCH_SEL:-1}"
[[ "$ARCH_SEL" =~ ^[0-9]+$ ]] && [ "$ARCH_SEL" -ge 1 ] && [ "$ARCH_SEL" -le "${#ARCHIVES[@]}" ] \
|| die "Invalid selection: $ARCH_SEL"
SELECTED_ARCHIVE="${ARCHIVES[$((ARCH_SEL-1))]}"
ok "Archive: $SELECTED_ARCHIVE"
TARGET_DIR="$DOCKER_BASE/$SELECTED_SVC"
COMPOSE_FILE="$TARGET_DIR/docker-compose.yml"
# ── Choose restore mode ───────────────────────────────────────────────────────
echo ""
echo "Restore mode:"
echo ""
echo " 1) Inspect — extract to /tmp so you can browse without touching live data"
echo " 2) Restore — move current data aside, restore archive in its place"
echo " old data kept as .pre-restore-DATE (easy rollback)"
echo ""
read -rp "Select [1/2] or q to quit: " MODE
[[ "$MODE" =~ ^[qQ]$ ]] && echo "Cancelled." && exit 0
case "$MODE" in
1)
TEMP_DIR="/tmp/borg-inspect-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$TEMP_DIR"
echo ""
info "Extracting $SELECTED_ARCHIVE to $TEMP_DIR (nothing live is changed)..."
if ( cd "$TEMP_DIR" && b extract --progress "$BORG_REPO::$SELECTED_ARCHIVE" ); then
echo ""
ok "Done. Browse the extracted files:"
echo ""
echo " ls -la $TEMP_DIR"
echo " Service directory: $TEMP_DIR${TARGET_DIR}"
echo ""
echo " When finished:"
echo " rm -rf $TEMP_DIR"
else
rm -rf "$TEMP_DIR" 2>/dev/null || true
die "Extraction failed — no changes were made."
fi
;;
2)
STOPPED=false
if [ -f "$COMPOSE_FILE" ] \
&& docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "$SELECTED_SVC"; then
echo ""
warn "Container '$SELECTED_SVC' is currently running."
read -rp " Stop it before restoring? Recommended to avoid corruption. (Y/n): " STOP_YN
if [[ ! "$STOP_YN" =~ ^[Nn]$ ]]; then
info "Stopping $SELECTED_SVC..."
docker compose -f "$COMPOSE_FILE" down 2>/dev/null \
|| docker stop "$SELECTED_SVC" 2>/dev/null \
|| warn "Could not stop $SELECTED_SVC — continuing anyway."
STOPPED=true
ok "$SELECTED_SVC stopped."
else
warn "Restoring with $SELECTED_SVC running — consistency not guaranteed."
fi
fi
ASIDE="${TARGET_DIR}.pre-restore-$(date +%Y%m%d-%H%M%S)"
echo ""
if [ -e "$TARGET_DIR" ]; then
info "Moving current data aside → $(basename "$ASIDE")"
mv "$TARGET_DIR" "$ASIDE"
ok "Current data saved at: $ASIDE"
else
warn "$TARGET_DIR does not exist — restoring fresh."
fi
mkdir -p "$TARGET_DIR"
info "Restoring $SELECTED_ARCHIVE$TARGET_DIR ..."
EXTRACT_PATH="${TARGET_DIR#/}"
if ( cd / && b extract --progress "$BORG_REPO::$SELECTED_ARCHIVE" "$EXTRACT_PATH" ); then
ok "Restore complete."
else
err "Restore failed — rolling back to original data."
rm -rf "$TARGET_DIR" 2>/dev/null || true
if [ -e "$ASIDE" ]; then
mv "$ASIDE" "$TARGET_DIR"
ok "Original data recovered from aside copy."
fi
if [ "$STOPPED" = true ] && [ -f "$COMPOSE_FILE" ]; then
docker compose -f "$COMPOSE_FILE" up -d 2>/dev/null || true
fi
exit 1
fi
if [ "$STOPPED" = true ] && [ -f "$COMPOSE_FILE" ]; then
echo ""
read -rp " Start '$SELECTED_SVC' now? (Y/n): " START_YN
if [[ ! "$START_YN" =~ ^[Nn]$ ]]; then
info "Starting $SELECTED_SVC..."
docker compose -f "$COMPOSE_FILE" up -d 2>/dev/null \
&& ok "$SELECTED_SVC started." \
|| warn "Start failed — check: docker compose -f $COMPOSE_FILE logs"
fi
fi
echo ""
echo "═══════════════════════════════════════════════════════"
echo " RESTORE COMPLETE"
echo "═══════════════════════════════════════════════════════"
echo ""
echo " Restored from : $SELECTED_ARCHIVE"
echo " Restored to : $TARGET_DIR"
[ -e "$ASIDE" ] && echo " Previous data : $ASIDE"
echo ""
if [ -e "$ASIDE" ]; then
echo " Keep the restore (delete aside copy when satisfied):"
echo " rm -rf \"$ASIDE\""
echo ""
echo " Roll back to previous data:"
[ -f "$COMPOSE_FILE" ] && echo " docker compose -f $COMPOSE_FILE down"
echo " rm -rf \"$TARGET_DIR\""
echo " mv \"$ASIDE\" \"$TARGET_DIR\""
[ -f "$COMPOSE_FILE" ] && echo " docker compose -f $COMPOSE_FILE up -d"
fi
echo ""
;;
*)
echo "Cancelled."
exit 0
;;
esac
@@ -1,13 +1,14 @@
#!/bin/bash
# extras/restore_kopia_backup.sh — interactive restore from a Kopia snapshot.
# Installed to ~/docker/backup/ by the backup service installer.
# extras/restore_kopia.sh — interactive restore from a Kopia snapshot.
# Installed to ~/docker/backup/ and ~/docker/gaming-backup/ by their installers.
#
# Run as root:
# sudo ./restore_kopia_backup.sh interactive
# sudo ./restore_kopia_backup.sh --list list all snapshot sources and exit
# sudo ./restore_kopia.sh interactive
# sudo ./restore_kopia.sh --list list all snapshot sources and exit
#
# Reads backup.conf from the same directory. The repository password is stored
# there (chmod 600, root-only) — no password prompt needed.
# Reads backup.conf from the same directory (chmod 600, root-only).
# Supports both multi-destination (backup service) and single-destination
# (gaming-backup service) conf formats.
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -22,29 +23,61 @@ die() { err "$*"; exit 1; }
# ── Preflight ─────────────────────────────────────────────────────────────────
[ "${EUID:-$(id -u)}" -eq 0 ] || die "Run as root: sudo $0"
[ -f "$CONF" ] || die "backup.conf not found: $CONF (run: sudo setup.sh backup)"
[ -f "$CONF" ] || die "backup.conf not found: $CONF"
command -v jq >/dev/null 2>&1 || die "jq is required — install it: sudo apt install jq"
# shellcheck source=/dev/null
source "$CONF"
export KOPIA_PASSWORD
# ── Normalise conf format ─────────────────────────────────────────────────────
# gaming-backup uses KOPIA_CONFIG/KOPIA_PASSWORD directly (single dest).
# backup service uses DEST_NAMES + DEST_<n>_CONFIG/PASSWORD (multi-dest).
# Normalise both to the multi-dest interface so the rest of the script is uniform.
if [ -z "${DEST_NAMES:-}" ]; then
DEST_NAMES="default"
DEST_default_CONFIG="${KOPIA_CONFIG:-}"
DEST_default_PASSWORD="${KOPIA_PASSWORD:-}"
fi
command -v "$KOPIA" >/dev/null 2>&1 || die "Kopia not found: $KOPIA"
k() { "$KOPIA" --config-file="$KOPIA_CONFIG" "$@"; }
k repository status >/dev/null 2>&1 || die "Cannot connect to Kopia repository. Check backup.conf."
# ── Derive Docker dir (mirrors lib/common.sh logic) ───────────────────────────
ACTUAL_USER="${SUDO_USER:-${USER:-$(id -un)}}"
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "/home/$ACTUAL_USER")"
DOCKER_BASE="$ACTUAL_HOME/docker"
# ── Destination picker (skipped when only one dest) ───────────────────────────
read -ra _DEST_ARR <<< "$DEST_NAMES"
if [ "${#_DEST_ARR[@]}" -gt 1 ]; then
echo ""
echo "Select backup destination:"
echo ""
for i in "${!_DEST_ARR[@]}"; do
_cfg_var="DEST_${_DEST_ARR[$i]}_CONFIG"
printf " %d) %s (%s)\n" "$((i+1))" "${_DEST_ARR[$i]}" "${!_cfg_var:-unknown}"
done
echo ""
read -rp "Destination [1-${#_DEST_ARR[@]}]: " _DEST_SEL
[[ "$_DEST_SEL" =~ ^[0-9]+$ ]] && [ "$_DEST_SEL" -ge 1 ] && [ "$_DEST_SEL" -le "${#_DEST_ARR[@]}" ] \
|| die "Invalid selection"
ACTIVE_DEST="${_DEST_ARR[$((_DEST_SEL-1))]}"
else
ACTIVE_DEST="${_DEST_ARR[0]}"
fi
_CFG_VAR="DEST_${ACTIVE_DEST}_CONFIG"
_PW_VAR="DEST_${ACTIVE_DEST}_PASSWORD"
KOPIA_CONFIG="${!_CFG_VAR:-}"
KOPIA_PASSWORD="${!_PW_VAR:-}"
[ -n "$KOPIA_CONFIG" ] || die "No KOPIA_CONFIG found for destination '$ACTIVE_DEST'"
export KOPIA_PASSWORD
k() { "$KOPIA" --config-file="$KOPIA_CONFIG" "$@"; }
k repository status >/dev/null 2>&1 || die "Cannot connect to Kopia repository '$ACTIVE_DEST'. Check backup.conf."
# ── Snapshot helpers ──────────────────────────────────────────────────────────
all_snapshots_json() {
k snapshot list --all --json 2>/dev/null
}
all_snapshots_json() { k snapshot list --all --json 2>/dev/null; }
# Given a source path like ~/docker/minecraft/data, return "service_name /path/to/compose.yml"
# or empty string if not a Docker service.
docker_info_for_path() {
local path="$1"
[[ "$path" == "$DOCKER_BASE"/* ]] || return 0
@@ -57,12 +90,9 @@ docker_info_for_path() {
# ── --list ────────────────────────────────────────────────────────────────────
if [ "${1:-}" = "--list" ]; then
echo ""
info "Loading snapshots..."
JSON=$(all_snapshots_json)
info "Loading snapshots (dest: $ACTIVE_DEST)..."
echo ""
echo "Backup sources:"
echo ""
echo "$JSON" | jq -r '
all_snapshots_json | jq -r '
group_by(.source.path)[] |
(.[0].source.path) as $p |
(.[0].description // "-") as $d |
@@ -76,22 +106,20 @@ fi
# ── Interactive restore ───────────────────────────────────────────────────────
echo ""
echo "╔═══════════════════════════════════════════════════════╗"
echo "║ Kopia Backup Restore ║"
echo "║ Kopia Restore ║"
echo "╚═══════════════════════════════════════════════════════╝"
echo ""
[ "${#_DEST_ARR[@]}" -gt 1 ] && info "Using destination: $ACTIVE_DEST"
info "Loading snapshot index..."
SNAP_JSON=$(all_snapshots_json)
[ -z "$SNAP_JSON" ] || [ "$SNAP_JSON" = "[]" ] || [ "$SNAP_JSON" = "null" ] \
&& die "No snapshots found. Run a backup first: sudo $HERE/backup.sh"
&& die "No snapshots found. Run a backup first: sudo $HERE/backup_kopia.sh"
# Build source arrays (newest-first per source)
mapfile -t SRC_PATHS < <(echo "$SNAP_JSON" | jq -r 'group_by(.source.path)[] | .[0].source.path')
mapfile -t SRC_DESCS < <(echo "$SNAP_JSON" | jq -r 'group_by(.source.path)[] | .[0].description // "-"')
mapfile -t SRC_LATEST < <(echo "$SNAP_JSON" | jq -r 'group_by(.source.path)[] | .[0].startTime | split("T") | "\(.[0]) \(.[1][:8])"')
mapfile -t SRC_COUNTS < <(echo "$SNAP_JSON" | jq -r 'group_by(.source.path)[] | length')
[ "${#SRC_PATHS[@]}" -eq 0 ] && die "No snapshot sources found."
echo "What do you want to restore?"
@@ -121,7 +149,6 @@ mapfile -t SNAP_IDS < <(echo "$SNAP_JSON" | jq -r --arg p "$SOURCE_PATH" '
mapfile -t SNAP_TIMES < <(echo "$SNAP_JSON" | jq -r --arg p "$SOURCE_PATH" '
[.[] | select(.source.path == $p)] | sort_by(.startTime) | reverse | .[0:15] |
.[].startTime | split("T") | "\(.[0]) \(.[1][:8]) UTC"')
[ "${#SNAP_IDS[@]}" -eq 0 ] && die "No snapshots found for that source."
for i in "${!SNAP_IDS[@]}"; do
@@ -153,8 +180,6 @@ read -rp "Select [1/2] or q to quit: " MODE
[[ "$MODE" =~ ^[qQ]$ ]] && echo "Cancelled." && exit 0
case "$MODE" in
# ── Inspect: restore to /tmp, nothing touched ─────────────────────────────────
1)
TEMP_DIR="/tmp/kopia-inspect-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$TEMP_DIR"
@@ -174,15 +199,12 @@ case "$MODE" in
fi
;;
# ── Restore in place: move aside, restore, offer rollback instructions ─────────
2)
SINFO=$(docker_info_for_path "$SOURCE_PATH")
SVC_NAME="${SINFO%% *}"
COMPOSE_FILE="${SINFO##* }"
# If no match, both vars will be empty or equal
[ "$SVC_NAME" = "$COMPOSE_FILE" ] && SVC_NAME="" && COMPOSE_FILE=""
# Stop associated Docker service if running
STOPPED=false
if [ -n "$SVC_NAME" ] && [ -n "$COMPOSE_FILE" ] \
&& docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "$SVC_NAME"; then
@@ -201,7 +223,6 @@ case "$MODE" in
fi
fi
# Move current data aside
ASIDE="${SOURCE_PATH}.pre-restore-$(date +%Y%m%d-%H%M%S)"
echo ""
if [ -e "$SOURCE_PATH" ]; then
@@ -212,7 +233,6 @@ case "$MODE" in
warn "Source path doesn't exist yet: $SOURCE_PATH — restoring fresh."
fi
# Restore snapshot
mkdir -p "$SOURCE_PATH"
info "Restoring $SNAPSHOT_TIME$SOURCE_PATH ..."
if k restore "$SNAPSHOT_ID" "$SOURCE_PATH"; then
@@ -230,7 +250,6 @@ case "$MODE" in
exit 1
fi
# Restart container
if [ "$STOPPED" = true ] && [ -n "$COMPOSE_FILE" ]; then
echo ""
read -rp " Start '$SVC_NAME' now? (Y/n): " START_YN
@@ -251,17 +270,15 @@ case "$MODE" in
echo " Restored to : $SOURCE_PATH"
[ -e "$ASIDE" ] && echo " Previous data : $ASIDE"
echo ""
echo " Verify your data, then:"
echo ""
if [ -e "$ASIDE" ]; then
echo " Keep the restore (delete aside copy when satisfied):"
echo " rm -rf \"$ASIDE\""
echo " Keep the restore (delete aside copy when satisfied):"
echo " rm -rf \"$ASIDE\""
echo ""
echo " Roll back to previous data:"
[ -n "$COMPOSE_FILE" ] && echo " docker compose -f $COMPOSE_FILE down"
echo " rm -rf \"$SOURCE_PATH\""
echo " mv \"$ASIDE\" \"$SOURCE_PATH\""
[ -n "$COMPOSE_FILE" ] && echo " docker compose -f $COMPOSE_FILE up -d"
echo " Roll back to previous data:"
[ -n "$COMPOSE_FILE" ] && echo " docker compose -f $COMPOSE_FILE down"
echo " rm -rf \"$SOURCE_PATH\""
echo " mv \"$ASIDE\" \"$SOURCE_PATH\""
[ -n "$COMPOSE_FILE" ] && echo " docker compose -f $COMPOSE_FILE up -d"
fi
echo ""
;;
+22 -160
View File
@@ -11,9 +11,9 @@
# New services are auto-discovered on every run — no reconfiguration needed.
#
# Creates: ~/docker/backup/
# backup.conf settings + per-service destination map (chmod 600)
# backup.sh worker (run directly or via systemd timer)
# restore/<dest>/ restore_kopia_backup.sh + backup.conf per destination
# backup.conf settings + per-service destination map (chmod 600)
# backup_kopia.sh worker (run directly or via systemd timer)
# restore_kopia.sh interactive restore helper
register_service backup backup "Encrypted backup of all Docker services (full restore)"
@@ -22,9 +22,8 @@ install_backup() {
local DIR="$DOCKER_DIR/backup"
local CONF_FILE="$DIR/backup.conf"
local WORKER="$DIR/backup.sh"
local RESTORE_DIR="$DIR/restore"
local RESTORE_SRC="${HERE:-}/extras/restore_kopia_backup.sh"
local WORKER="$DIR/backup_kopia.sh"
local RESTORE="$DIR/restore_kopia.sh"
local SVC_NAME="post-install-backup"
echo ""
@@ -248,7 +247,7 @@ install_backup() {
KEEP_LATEST="${KEEP_LATEST:-7}"
# ── 7. Create dirs + init Kopia repos ────────────────────────────────────
mkdir -p "$DIR" "$RESTORE_DIR"
mkdir -p "$DIR"
ensure_docker_dir_ownership "$DIR"
local repo pw cfg
@@ -288,7 +287,7 @@ install_backup() {
echo "# ── backup.conf ────────────────────────────────────────────────────────────"
echo "# Generated $(date '+%F %T'). Safe to hand-edit."
echo "# Worker : sudo $WORKER"
echo "# Restore: sudo $RESTORE_DIR/<dest>/restore_kopia_backup.sh"
echo "# Restore: sudo $RESTORE"
echo ""
echo "KOPIA=\"$KOPIA_BIN\""
echo ""
@@ -326,158 +325,23 @@ install_backup() {
chmod 600 "$CONF_FILE"
log_success "backup.conf written (chmod 600)"
# ── 9. Generate worker script ─────────────────────────────────────────────
log_info "Writing worker $WORKER ..."
cat > "$WORKER" << 'WORKEREOF'
#!/bin/bash
# Generated by the backup installer.
# Backs up full ~/docker/<service>/ directories via Kopia.
# Minecraft instances: flush to disk (save-all) then snapshot — no downtime.
# All other services: stop → snapshot → restart for consistency.
#
# sudo ./backup.sh run a full backup cycle
# sudo ./backup.sh snapshots list all snapshots (all repos)
# sudo ./backup.sh policy show retention policies
#
# Reads backup.conf from the same directory.
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONF="${BACKUP_CONF:-$HERE/backup.conf}"
[ -f "$CONF" ] || { echo "Config not found: $CONF (re-run the backup service)"; exit 1; }
# shellcheck source=/dev/null
source "$CONF"
ACTUAL_USER="${SUDO_USER:-${USER:-$(id -un)}}"
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "/home/$ACTUAL_USER")"
DOCKER_DIR="$ACTUAL_HOME/docker"
log() { echo "[$(date '+%F %T')] $*"; }
kp_for() {
local dest="$1"; shift
local cfg_var="DEST_${dest}_CONFIG" pw_var="DEST_${dest}_PASSWORD"
local cfg="${!cfg_var:-}" pw="${!pw_var:-}"
[ -n "$cfg" ] || { log "Unknown destination: $dest"; return 1; }
env KOPIA_PASSWORD="$pw" "$KOPIA" --config-file="$cfg" "$@"
}
dest_for_svc() {
local var="SVC_${1//-/_}"
echo "${!var:-${DEST_DEFAULT:-default}}"
}
# Detect itzg Minecraft instances by their Dockerfile signature.
is_minecraft() { [ -f "${1}Dockerfile" ] && grep -qs itzg "${1}Dockerfile"; }
case "${1:-run}" in
snapshots)
for dest in ${DEST_NAMES:-default}; do
echo ""; echo "── dest: $dest ──"
kp_for "$dest" snapshot list 2>/dev/null || true
done
exit 0 ;;
policy)
for dest in ${DEST_NAMES:-default}; do
echo ""; echo "── dest: $dest ──"
kp_for "$dest" policy show --global 2>/dev/null || true
done
exit 0 ;;
esac
log "===== Backup starting ====="
rc=0
for svc_dir in "$DOCKER_DIR"/*/; do
[ -f "${svc_dir}docker-compose.yml" ] || continue
svc="$(basename "$svc_dir")"
[[ "$svc" == "backup" || "$svc" == "gaming-backup" ]] && continue
dest="$(dest_for_svc "$svc")"
_repo_var="DEST_${dest}_REPO"
[ -n "${!_repo_var:-}" ] || { log "SKIP $svc — dest '$dest' not configured in conf"; continue; }
if is_minecraft "$svc_dir"; then
# ── Minecraft: flush world to disk, snapshot without stopping ────────
if docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "$svc"; then
log "Flushing Minecraft world '$svc' (save-all, no downtime)..."
docker exec "$svc" mc-send-to-console save-all flush 2>/dev/null \
|| docker exec "$svc" rcon-cli save-all 2>/dev/null || true
sleep 5
fi
log "Snapshotting $svc (dest: $dest)..."
if kp_for "$dest" snapshot create --description="backup: $svc" "$svc_dir"; then
log "OK $svc (Minecraft, no downtime)"
else
log "WARNING: snapshot failed for $svc"; rc=1
fi
else
# ── All other services: stop → snapshot → restart ─────────────────
STOPPED=false
if docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "$svc"; then
log "Stopping $svc..."
docker compose -f "${svc_dir}docker-compose.yml" down 2>/dev/null \
|| docker stop "$svc" 2>/dev/null \
|| log "WARNING: could not stop $svc — snapshotting live (consistency not guaranteed)"
STOPPED=true
fi
log "Snapshotting $svc (dest: $dest)..."
if kp_for "$dest" snapshot create --description="backup: $svc" "$svc_dir"; then
log "OK $svc"
else
log "WARNING: snapshot failed for $svc"; rc=1
fi
if [ "$STOPPED" = true ]; then
log "Starting $svc..."
docker compose -f "${svc_dir}docker-compose.yml" up -d 2>/dev/null \
|| log "WARNING: could not restart $svc — run: docker compose -f ${svc_dir}docker-compose.yml up -d"
fi
fi
done
if [ "${REMOTE_TYPE:-none}" != "none" ] && [ -n "${REMOTE_TYPE:-}" ]; then
for dest in ${DEST_NAMES:-default}; do
log "Mirroring '$dest' offsite ($REMOTE_TYPE)..."
# shellcheck disable=SC2086
kp_for "$dest" repository sync-to "$REMOTE_TYPE" $REMOTE_ARGS \
|| { log "WARNING: mirror failed for '$dest'"; rc=1; }
done
fi
if [ "$rc" -eq 0 ]; then
log "===== Backup complete ====="
else
log "===== Backup finished WITH WARNINGS (see above) ====="
fi
exit "$rc"
WORKEREOF
# ── 9. Install worker script ─────────────────────────────────────────────
log_info "Installing worker $WORKER ..."
cp "${HERE:-}/extras/backup_kopia.sh" "$WORKER"
chmod +x "$WORKER"
chown root:root "$WORKER" 2>/dev/null || true
log_success "backup.sh written"
log_success "backup_kopia.sh installed"
# ── 10. Restore scripts (one per destination) ────────────────────────────
# ── 10. Install restore script ────────────────────────────────────────────
local RESTORE_SRC="${HERE:-}/extras/restore_kopia.sh"
if [ -f "$RESTORE_SRC" ]; then
for dn in "${DEST_NAMES_ARR[@]}"; do
local dest_rdir="$RESTORE_DIR/$dn"
mkdir -p "$dest_rdir"
cp "$RESTORE_SRC" "$dest_rdir/restore_kopia_backup.sh"
chmod +x "$dest_rdir/restore_kopia_backup.sh"
{
echo "# backup.conf for backup destination '$dn'"
echo "# Read by restore_kopia_backup.sh in this directory."
echo "KOPIA=\"$KOPIA_BIN\""
echo "KOPIA_CONFIG=\"${DEST_CONFIGS[$dn]}\""
printf "KOPIA_PASSWORD='%s'\n" "${DEST_PASSWORDS[$dn]}"
} > "$dest_rdir/backup.conf"
chown root:root "$dest_rdir/backup.conf" 2>/dev/null || true
chmod 600 "$dest_rdir/backup.conf"
log_success "restore/$dn/ ready"
done
cp "$RESTORE_SRC" "$RESTORE"
chmod +x "$RESTORE"
chown root:root "$RESTORE" 2>/dev/null || true
log_success "restore_kopia.sh installed"
else
log_warning "extras/restore_kopia_backup.sh not found — restore scripts not installed"
log_warning "Copy it manually: cp extras/restore_kopia_backup.sh $RESTORE_DIR/<dest>/"
log_warning "extras/restore_kopia.sh not found — restore script not installed"
log_warning "Copy it manually: cp extras/restore_kopia.sh $RESTORE"
fi
# ── 11. Systemd timer ────────────────────────────────────────────────────
@@ -559,11 +423,9 @@ SVCEOF
echo " sudo $WORKER back up now"
echo " sudo $WORKER snapshots list all snapshots"
echo ""
echo " Restore (per destination):"
for dn in "${DEST_NAMES_ARR[@]}"; do
echo " sudo $RESTORE_DIR/$dn/restore_kopia_backup.sh"
echo " sudo $RESTORE_DIR/$dn/restore_kopia_backup.sh --list"
done
echo " Restore:"
echo " sudo $RESTORE"
echo " sudo $RESTORE --list"
echo ""
[ -n "$AUTORUN" ] && echo " $AUTORUN" && echo ""
log_warning "Save your passwords (in backup.conf) somewhere safe —"
+382
View File
@@ -0,0 +1,382 @@
#!/bin/bash
# services/borg-backup.sh — Full Docker-service backup via Borg.
# Part of the modular post-install system (sourced by setup.sh).
#
# Backs up each entire ~/docker/<service>/ directory (compose file, config,
# data, databases — everything needed to restore from nothing).
# Minecraft instances: flush world (save-all), snapshot, no downtime
# All other services: stop → snapshot → restart for consistency
#
# Borg advantages over Kopia: mature tooling, Borgmatic YAML config option,
# Vorta GUI, SSH remote repos out of the box, widely packaged.
#
# Creates: ~/docker/borg-backup/
# backup.conf settings + per-dest repo/passphrase (chmod 600)
# backup_borg.sh worker (run directly or via systemd timer)
# restore_borg.sh interactive restore helper
register_service borg-backup backup "Encrypted backup of all Docker services via Borg"
install_borg_backup() {
require_docker || return 1
local DIR="$DOCKER_DIR/borg-backup"
local CONF_FILE="$DIR/backup.conf"
local WORKER="$DIR/backup_borg.sh"
local RESTORE="$DIR/restore_borg.sh"
local SVC_NAME="post-install-borg-backup"
echo ""
echo "╔═══════════════════════════════════════════════════════╗"
echo "║ Borg Backup Setup ║"
echo "║ Full ~/docker/<service>/ snapshots ║"
echo "╚═══════════════════════════════════════════════════════╝"
echo ""
echo " Backs up each entire service directory — compose file, config, data,"
echo " databases, everything needed to restore a service from scratch."
echo ""
echo " Minecraft: world flushed to disk (save-all), snapshot, NO downtime."
echo " Everything else: stopped briefly, snapshotted, restarted."
echo ""
echo " Borg supports local paths AND remote repos over SSH:"
echo " local: /mnt/backup-drive/borg-repo"
echo " remote: user@hostname:/path/to/repo"
echo " ssh://user@hostname:2222/path/to/repo"
echo " Remote repos require passwordless SSH key access to the remote host."
echo ""
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would discover services under $DOCKER_DIR"
echo "[DRY-RUN] Would create $DIR with conf, worker, and restore scripts"
echo "[DRY-RUN] Would init Borg repo(s) at user-specified paths"
echo "[DRY-RUN] Would install systemd timer"
return 0
fi
# ── 1. Borg ──────────────────────────────────────────────────────────────
if ! command -v borg >/dev/null 2>&1; then
log_info "Installing borgbackup..."
apt-get install -y borgbackup \
|| { log_error "Failed to install borgbackup. Try: sudo apt install borgbackup"; return 1; }
fi
local BORG_BIN; BORG_BIN="$(command -v borg)"
log_success "Borg: $("$BORG_BIN" --version 2>/dev/null)"
# ── 2. Discover installed services ───────────────────────────────────────
local -a ALL_SVCS=()
local d svc
for d in "$DOCKER_DIR"/*/; do
[ -f "${d}docker-compose.yml" ] || continue
svc="$(basename "$d")"
[[ "$svc" == "borg-backup" || "$svc" == "backup" || "$svc" == "gaming-backup" ]] && continue
ALL_SVCS+=("$svc")
done
if [ "${#ALL_SVCS[@]}" -eq 0 ]; then
log_warning "No services found under $DOCKER_DIR — auto-detected on each backup run."
else
log_info "Services found: ${ALL_SVCS[*]}"
fi
# ── 3. Destinations ───────────────────────────────────────────────────────
echo ""
echo "═══════════════════════════════════════════════════════"
echo " BACKUP DESTINATIONS"
echo "═══════════════════════════════════════════════════════"
echo ""
echo " Each destination is a Borg repository (local path or user@host:/path)."
echo " For best resilience: use a different drive or remote host from your data."
echo " Destination names must be letters, numbers, and underscores only."
echo ""
local DEFAULT_DEST="$ACTUAL_HOME/backups/borg-repo"
local _repo=""
prompt_text " Default repository path [${DEFAULT_DEST}]:" "$DEFAULT_DEST" _repo
_repo="${_repo/#\~/$ACTUAL_HOME}"; _repo="${_repo%/}"
local -a DEST_NAMES_ARR=("default")
local -A DEST_REPOS=() DEST_PASSWORDS=()
DEST_REPOS["default"]="$_repo"
local _extra=""
prompt_yn " Add more destinations (for services on different drives)? (y/N):" "n" _extra
if [[ "$_extra" =~ ^[Yy]$ ]]; then
echo ""
local _dn _dr
while true; do
prompt_text " Destination name (blank to finish):" "" _dn
[ -z "$_dn" ] && break
_dn="${_dn//[^a-zA-Z0-9_]/_}"
[ "$_dn" = "default" ] && { log_warning " 'default' is reserved — use another name."; continue; }
prompt_text " Path for '$_dn' repository:" "" _dr
[ -z "$_dr" ] && continue
_dr="${_dr/#\~/$ACTUAL_HOME}"; _dr="${_dr%/}"
DEST_REPOS["$_dn"]="$_dr"
DEST_NAMES_ARR+=("$_dn")
log_success " Destination '$_dn' → $_dr"
done
fi
# ── 4. Service → destination assignment ──────────────────────────────────
local -A SVC_DEST_MAP=()
if [ "${#ALL_SVCS[@]}" -gt 0 ] && [ "${#DEST_NAMES_ARR[@]}" -gt 1 ]; then
echo ""
echo "═══════════════════════════════════════════════════════"
echo " ASSIGN SERVICES TO DESTINATIONS"
echo "═══════════════════════════════════════════════════════"
echo ""
echo " Destinations:"
local dn
for dn in "${DEST_NAMES_ARR[@]}"; do
printf " %-16s %s\n" "$dn" "${DEST_REPOS[$dn]}"
done
echo ""
echo " Press Enter to accept the default for each service."
echo ""
local _d
for svc in "${ALL_SVCS[@]}"; do
prompt_text " $svc [default]:" "default" _d
if [ -n "$_d" ] && [ "$_d" != "default" ] && [ -n "${DEST_REPOS[$_d]:-}" ]; then
SVC_DEST_MAP["$svc"]="$_d"
fi
done
fi
# ── 5. Passwords ─────────────────────────────────────────────────────────
echo ""
log_info "Setting repository passphrases (stored in backup.conf, chmod 600)..."
for dn in "${DEST_NAMES_ARR[@]}"; do
local pw=""
if [ "$UNATTENDED" = true ]; then
pw="$(generate_password 32)"
else
read -rsp " Passphrase for '$dn' [Enter = auto-generate]: " pw; echo
fi
[ -z "$pw" ] && pw="$(generate_password 32)" && log_info " Auto-generated passphrase for '$dn'."
DEST_PASSWORDS["$dn"]="$pw"
done
# ── 6. Schedule & retention ──────────────────────────────────────────────
echo ""
echo "═══════════════════════════════════════════════════════"
echo " SCHEDULE & RETENTION"
echo "═══════════════════════════════════════════════════════"
echo ""
echo " Minecraft runs uninterrupted; other services stop briefly (seconds each)."
echo " Schedule for off-peak hours."
echo ""
echo " 1) Daily at 02:00 (recommended)"
echo " 2) Every 12 hours"
echo " 3) Weekly (Sunday 02:00)"
echo " 4) Custom (systemd OnCalendar)"
echo ""
local _sch=""
prompt_text " How often? [1]:" "1" _sch
local ONCALENDAR SCHED_LABEL
case "${_sch:-1}" in
2) ONCALENDAR="*-*-* 02,14:00:00"; SCHED_LABEL="every 12 hours" ;;
3) ONCALENDAR="Sun *-*-* 02:00:00"; SCHED_LABEL="weekly Sunday 02:00" ;;
4) prompt_text " OnCalendar expression:" "*-*-* 02:00:00" ONCALENDAR; SCHED_LABEL="$ONCALENDAR" ;;
*) ONCALENDAR="*-*-* 02:00:00"; SCHED_LABEL="daily at 02:00" ;;
esac
echo ""
echo " Retention policy — Borg prunes per-service archives independently."
echo ""
local KEEP_DAILY="" KEEP_WEEKLY="" KEEP_MONTHLY=""
prompt_text " Keep last N daily archives per service [7]:" "7" KEEP_DAILY
prompt_text " Keep last N weekly archives per service [4]:" "4" KEEP_WEEKLY
prompt_text " Keep last N monthly archives per service [3]:" "3" KEEP_MONTHLY
KEEP_DAILY="${KEEP_DAILY:-7}"
KEEP_WEEKLY="${KEEP_WEEKLY:-4}"
KEEP_MONTHLY="${KEEP_MONTHLY:-3}"
# ── 7. Create dirs + init Borg repos ─────────────────────────────────────
mkdir -p "$DIR"
ensure_docker_dir_ownership "$DIR"
local repo pw
for dn in "${DEST_NAMES_ARR[@]}"; do
repo="${DEST_REPOS[$dn]}"
pw="${DEST_PASSWORDS[$dn]}"
# Skip init for remote repos — user must set them up manually with SSH access.
if [[ "$repo" == *@*:* ]] || [[ "$repo" == ssh://* ]]; then
log_info "Remote repo '$dn' ($repo) — checking connectivity..."
if BORG_PASSPHRASE="$pw" "$BORG_BIN" info "$repo" >/dev/null 2>&1; then
log_success "Remote repo '$dn' connected."
elif BORG_PASSPHRASE="$pw" "$BORG_BIN" init --encryption=repokey-blake2 "$repo" 2>/dev/null; then
log_success "Remote repo '$dn' initialised at $repo"
else
log_warning "Could not init remote repo '$dn' at $repo."
log_warning "Ensure SSH key access to the remote host is configured, then:"
log_warning " BORG_PASSPHRASE='${pw}' borg init --encryption=repokey-blake2 ${repo}"
fi
else
mkdir -p "$repo"
if BORG_PASSPHRASE="$pw" "$BORG_BIN" info "$repo" >/dev/null 2>&1; then
log_success "Repo '$dn' already exists at $repo."
else
log_info "Initialising repo '$dn' at $repo ..."
BORG_PASSPHRASE="$pw" "$BORG_BIN" init --encryption=repokey-blake2 "$repo" \
|| { log_error "Failed to init repo '$dn'."; return 1; }
log_success "Repo '$dn' initialised at $repo"
fi
fi
done
# ── 8. Write backup.conf ─────────────────────────────────────────────────
log_info "Writing $CONF_FILE ..."
{
echo "# ── backup.conf ────────────────────────────────────────────────────────────"
echo "# Generated $(date '+%F %T'). Safe to hand-edit."
echo "# Worker : sudo $WORKER"
echo "# Restore: sudo $RESTORE"
echo ""
echo "BORG=\"$BORG_BIN\""
echo ""
echo "# Space-separated list of destination names."
echo "DEST_NAMES=\"${DEST_NAMES_ARR[*]}\""
echo "DEST_DEFAULT=\"default\""
echo ""
echo "# Retention (applied per-service archive prefix)."
echo "KEEP_DAILY=$KEEP_DAILY"
echo "KEEP_WEEKLY=$KEEP_WEEKLY"
echo "KEEP_MONTHLY=$KEEP_MONTHLY"
echo ""
for dn in "${DEST_NAMES_ARR[@]}"; do
echo "# ── destination: $dn"
echo "DEST_${dn}_REPO=\"${DEST_REPOS[$dn]}\""
printf "DEST_%s_PASSPHRASE='%s'\n" "$dn" "${DEST_PASSWORDS[$dn]}"
echo ""
done
echo "# ── Service → destination map ───────────────────────────────────────────────"
echo "# Format: SVC_<name>=<dest_name> (hyphens become underscores)"
echo "# Omit or comment out to use DEST_DEFAULT."
for svc in "${ALL_SVCS[@]}"; do
local svc_var="${svc//-/_}"
local dest_val="${SVC_DEST_MAP[$svc]:-}"
if [ -n "$dest_val" ]; then
echo "SVC_${svc_var}=\"${dest_val}\""
else
echo "# SVC_${svc_var}=\"default\""
fi
done
} > "$CONF_FILE"
chown root:root "$CONF_FILE" 2>/dev/null || true
chmod 600 "$CONF_FILE"
log_success "backup.conf written (chmod 600)"
# ── 9. Install worker script ──────────────────────────────────────────────
log_info "Installing worker $WORKER ..."
cp "${HERE:-}/extras/backup_borg.sh" "$WORKER"
chmod +x "$WORKER"
chown root:root "$WORKER" 2>/dev/null || true
log_success "backup_borg.sh installed"
# ── 10. Install restore script ────────────────────────────────────────────
local RESTORE_SRC="${HERE:-}/extras/restore_borg.sh"
if [ -f "$RESTORE_SRC" ]; then
cp "$RESTORE_SRC" "$RESTORE"
chmod +x "$RESTORE"
chown root:root "$RESTORE" 2>/dev/null || true
log_success "restore_borg.sh installed"
else
log_warning "extras/restore_borg.sh not found — restore script not installed"
log_warning "Copy it manually: cp extras/restore_borg.sh $RESTORE"
fi
# ── 11. Systemd timer ─────────────────────────────────────────────────────
log_info "Installing systemd timer ($SCHED_LABEL)..."
if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then
tee "/etc/systemd/system/${SVC_NAME}.service" >/dev/null << SVCEOF
[Unit]
Description=Post-install Borg backup (full Docker service directories)
After=docker.service network-online.target
Wants=docker.service
[Service]
Type=oneshot
ExecStart=/bin/bash $WORKER run
SVCEOF
tee "/etc/systemd/system/${SVC_NAME}.timer" >/dev/null << SVCEOF
[Unit]
Description=Schedule post-install Borg backup ($SCHED_LABEL)
[Timer]
OnCalendar=$ONCALENDAR
Persistent=true
RandomizedDelaySec=300
[Install]
WantedBy=timers.target
SVCEOF
systemctl daemon-reload
systemctl enable --now "${SVC_NAME}.timer"
log_success "Timer enabled: $SCHED_LABEL"
else
log_warning "systemd not detected — installing cron fallback."
local CRON
case "${_sch:-1}" in
2) CRON="0 2,14 * * *" ;;
3) CRON="0 2 * * 0" ;;
*) CRON="0 2 * * *" ;;
esac
echo "$CRON root /bin/bash $WORKER run >> /var/log/${SVC_NAME}.log 2>&1" \
> "/etc/cron.d/${SVC_NAME}"
log_success "Cron job installed: $CRON"
fi
# ── 12. Optional first run ────────────────────────────────────────────────
echo ""
local _now=""
prompt_yn " Run the first backup now? (y/N):" "n" _now
if [[ "$_now" =~ ^[Yy]$ ]]; then
/bin/bash "$WORKER" run || log_warning "First backup reported warnings — check output above."
fi
# ── Summary ───────────────────────────────────────────────────────────────
echo ""
echo "═══════════════════════════════════════════════════════"
echo " BORG BACKUP CONFIGURED"
echo "═══════════════════════════════════════════════════════"
echo ""
echo " Config : $CONF_FILE"
echo " Worker : $WORKER"
echo " Schedule : $SCHED_LABEL"
echo " Retention: ${KEEP_DAILY}d daily / ${KEEP_WEEKLY}w weekly / ${KEEP_MONTHLY}m monthly (per service)"
echo ""
echo " Destinations:"
for dn in "${DEST_NAMES_ARR[@]}"; do
printf " %-16s %s\n" "$dn" "${DEST_REPOS[$dn]}"
done
echo ""
if [ "${#ALL_SVCS[@]}" -gt 0 ]; then
echo " Services backed up: ${ALL_SVCS[*]}"
else
echo " Services: none yet — auto-discovered on each run"
fi
echo ""
echo " Commands:"
echo " sudo $WORKER back up now"
echo " sudo $WORKER list list all archives"
echo " sudo $WORKER info repo stats"
echo ""
echo " Restore:"
echo " sudo $RESTORE"
echo " sudo $RESTORE --list"
echo ""
log_warning "IMPORTANT — back up your Borg key and passphrase now."
echo " The key is stored in the repo itself (repokey-blake2 encryption)."
echo " Export it to a safe location:"
for dn in "${DEST_NAMES_ARR[@]}"; do
local _rp="${DEST_REPOS[$dn]}"
local _pw="${DEST_PASSWORDS[$dn]}"
echo " BORG_PASSPHRASE='${_pw}' borg key export ${_rp} ~/borg-key-${dn}.txt"
done
echo " Store the exported key file and passphrase somewhere that is NOT"
echo " on this machine (e.g. USB drive, password manager, offsite)."
echo ""
}
+11 -96
View File
@@ -26,7 +26,7 @@ install_gaming_backup() {
# ── Repo-conventional paths ──────────────────────────────────────────────
local BACKUP_DIR="$DOCKER_DIR/gaming-backup"
local CONF_FILE="$BACKUP_DIR/backup.conf" # editable settings
local WORKER="$BACKUP_DIR/gaming-backup.sh" # generated worker
local WORKER="$BACKUP_DIR/backup_gaming.sh" # worker script
local KOPIA_CONFIG="/etc/gaming-backup/repository.config"
local CACHE_DIR="/var/cache/gaming-backup"
local SVC_NAME="post-install-gaming-backup"
@@ -273,108 +273,23 @@ CONFEOF
|| log_warning "Could not pre-set Steam ignore policy (applies on first snapshot)."
fi
# ── 7. Generate the worker script ────────────────────────────────────────
log_info "Writing worker $WORKER ..."
cat > "$WORKER" << 'WORKEREOF'
#!/bin/bash
# Generated by the gaming-backup service — frequent game-save snapshots via Kopia.
# Nothing is stopped: Minecraft worlds are flushed to disk (save-all) first.
#
# sudo ./gaming-backup.sh run a backup now
# sudo ./gaming-backup.sh snapshots list snapshots
# sudo ./gaming-backup.sh policy show retention/ignore policy
#
# Reads settings from backup.conf next to this script.
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONF="${BACKUP_CONF:-$HERE/backup.conf}"
[ -f "$CONF" ] || { echo "Config not found: $CONF (re-run the gaming-backup service)"; exit 1; }
# shellcheck source=/dev/null
source "$CONF"
export KOPIA_PASSWORD
log() { echo "[$(date '+%F %T')] $*"; }
k() { "$KOPIA" --config-file="$KOPIA_CONFIG" "$@"; }
if ! k repository status >/dev/null 2>&1; then
log "ERROR: not connected to a repository — re-run the gaming-backup service"
exit 1
fi
case "${1:-run}" in
snapshots) k snapshot list; exit 0 ;;
policy) k policy show --global; exit 0 ;;
esac
log "===== Gaming backup starting ====="
# Flush each running Minecraft world to disk first so snapshots are consistent.
if [ -n "${MC_BASE_DIR:-}" ] && command -v docker >/dev/null 2>&1; then
_flushed=0
for d in "$MC_BASE_DIR"/*/; do
[ -f "${d}Dockerfile" ] && grep -qs itzg "${d}Dockerfile" || continue
name="$(basename "$d")"
if docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "$name"; then
log "Flushing Minecraft world '$name' (save-all)..."
docker exec "$name" mc-send-to-console save-all flush 2>/dev/null \
|| docker exec "$name" rcon-cli save-all 2>/dev/null || true
_flushed=1
fi
done
[ "$_flushed" = 1 ] && sleep 5
fi
rc=0
snap() {
local label="$1" path="$2"
if [ -z "$path" ] || [ ! -e "$path" ]; then
log "skip $label — not found: ${path:-<unset>}"; return
fi
log "Snapshotting $label: $path"
if ! k snapshot create --description="gaming: $label" "$path"; then
log "WARNING: snapshot failed for $label"; rc=1
fi
}
if [ -n "${MC_BASE_DIR:-}" ]; then
for d in "$MC_BASE_DIR"/*/; do
[ -f "${d}Dockerfile" ] && grep -qs itzg "${d}Dockerfile" && [ -d "${d}data" ] || continue
nm="$(basename "$d")"
case "$nm" in minecraft*) lbl="$nm" ;; *) lbl="minecraft-$nm" ;; esac
snap "$lbl" "${d}data"
done
fi
[ "${BACKUP_SAVES:-no}" = yes ] && snap "emulator-saves" "$GAME_STORAGE_DIR/saves"
[ "${BACKUP_STEAM:-no}" = yes ] && snap "steam-userdata" "$GAME_STORAGE_DIR/steam"
[ "${BACKUP_MEDIA:-no}" = yes ] && snap "es-de-media" "$GAME_STORAGE_DIR/media"
[ "${BACKUP_WOLF:-no}" = yes ] && snap "wolf-state" "$WOLF_STATE_DIR"
if [ "${REMOTE_TYPE:-none}" != "none" ] && [ -n "${REMOTE_TYPE:-}" ]; then
log "Mirroring repository to remote ($REMOTE_TYPE)..."
# shellcheck disable=SC2086
if ! k repository sync-to "$REMOTE_TYPE" $REMOTE_ARGS; then
log "WARNING: remote mirror failed"; rc=1
fi
fi
if [ "$rc" -eq 0 ]; then log "===== Gaming backup complete ====="; else log "===== Gaming backup finished WITH WARNINGS ====="; fi
exit "$rc"
WORKEREOF
# ── 7. Install the worker script ────────────────────────────────────────
log_info "Installing worker $WORKER ..."
cp "${HERE:-}/extras/backup_gaming.sh" "$WORKER"
chmod +x "$WORKER"
chown root:root "$WORKER" 2>/dev/null || true
log_success "gaming-backup.sh written"
log_success "backup_gaming.sh installed"
# ── Copy the interactive restore script ───────────────────────────────────
local RESTORE_SRC="${HERE:-}/extras/restore_kopia_backup.sh"
local RESTORE_DEST="$BACKUP_DIR/restore_kopia_backup.sh"
local RESTORE_SRC="${HERE:-}/extras/restore_kopia.sh"
local RESTORE_DEST="$BACKUP_DIR/restore_gaming.sh"
if [ -f "$RESTORE_SRC" ]; then
cp "$RESTORE_SRC" "$RESTORE_DEST"
chmod +x "$RESTORE_DEST"
chown root:root "$RESTORE_DEST" 2>/dev/null || true
log_success "restore_kopia_backup.sh installed"
log_success "restore_kopia.sh installed"
else
log_warning "extras/restore_kopia_backup.sh not found — restore script not installed"
log_warning "extras/restore_kopia.sh not found — restore script not installed"
fi
# ── 8. Install systemd timer (fallback: cron) ────────────────────────────
@@ -452,8 +367,8 @@ UNITEOF
echo " Commands:"
echo " sudo $WORKER back up now"
echo " sudo $WORKER snapshots list snapshots"
echo " sudo $RESTORE_DEST interactive restore"
echo " sudo $RESTORE_DEST --list list all snapshot sources"
echo " sudo $RESTORE_DEST interactive restore"
echo " sudo $RESTORE_DEST --list list all snapshot sources"
echo " $AUTORUN"
echo ""
echo " Tip: also install the 'backup' service for nightly full-service recovery."