Two stacked bugs, found together when re-running the backup installer to
add an SFTP mirror silently reverted a previously-set absolute
DR_SYNC_PATH back to the script's tilde-based default, which then failed
outright:
1. services/backup.sh never read DR_SYNC_HOST/DR_SYNC_PATH back from an
existing backup.conf before prompting (every other setting in this file
does — passwords, mirrors). Accepting the prompt defaults on a rerun
silently reset both to blank/"~/docker/backup" instead of keeping what
was already configured. Fixed by reading them back the same way
DEST_*_PASSWORD already does.
2. extras/backup_kopia.sh's DR-spare sync wraps the remote path in single
quotes for its `ssh host "mkdir -p '...'"` / `"chmod 600 '.../...'"`
commands. Single-quoting a leading ~ stops the remote shell from
expanding it at all, so it looked for a literal directory named "~"
instead of the home directory — breaking the script's own DEFAULT
DR_SYNC_PATH ("~/docker/backup") for anyone who actually used it.
rsync's own transfer step has separate, correct tilde handling, which is
why the sync itself "succeeded" while the follow-up chmod couldn't find
the file. Fixed with a small _dr_remote_quote() helper that keeps a
leading ~/ outside the quotes while still safely quoting the rest of
the path.
Verified the quoting fix by parsing the exact constructed command string
in bash directly — a plain '~/docker/backup' stays literal (the bug),
~/'docker/backup' correctly expands to $HOME/docker/backup (the fix).
292 lines
13 KiB
Bash
292 lines
13 KiB
Bash
#!/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"
|
|
HOST="$(hostname -s 2>/dev/null || hostname)"
|
|
|
|
log() { echo "[$(date '+%F %T')] $*"; }
|
|
|
|
ntfy_send() {
|
|
local title="$1" msg="$2" priority="${3:-default}" tags="${4:-}"
|
|
[ -z "${NTFY_URL:-}" ] && return 0
|
|
local -a _args=(-fsS -o /dev/null)
|
|
_args+=(-H "Title: $title" -H "Priority: $priority")
|
|
[ -n "$tags" ] && _args+=(-H "Tags: $tags")
|
|
[ -n "${NTFY_TOKEN:-}" ] && _args+=(-H "Authorization: Bearer $NTFY_TOKEN")
|
|
curl "${_args[@]}" -d "$msg" "$NTFY_URL" 2>/dev/null || true
|
|
}
|
|
|
|
categorize_error() {
|
|
local txt="$1"
|
|
if echo "$txt" | grep -qi "no space left\|disk quota exceeded"; then
|
|
echo "disk full — backup destination is out of space"
|
|
elif echo "$txt" | grep -qi "connection refused\|network unreachable\|no route to host\|ssh.*connect\|timed out\|host unreachable"; then
|
|
echo "remote unreachable — check network / destination host"
|
|
elif echo "$txt" | grep -qi "repository.*not.*exist\|not a valid kopia\|not connected"; then
|
|
echo "repository not found — re-run the backup installer"
|
|
elif echo "$txt" | grep -qi "passphrase\|wrong key\|cannot decrypt"; then
|
|
echo "wrong passphrase — check backup.conf"
|
|
elif echo "$txt" | grep -qi "permission denied\|access denied"; then
|
|
echo "permission denied — check file permissions"
|
|
else
|
|
echo "error — see system logs on $HOST"
|
|
fi
|
|
}
|
|
|
|
# Logs the raw stderr text a failure produced, truncated to a sane length.
|
|
# categorize_error()'s bucket alone ("error — see system logs") used to be
|
|
# the end of the trail for anything that didn't match one of its known
|
|
# patterns — the actual message was in a mktemp'd file this script deletes
|
|
# on exit (trap ... EXIT), so nothing was ever really "in system logs" to
|
|
# go look at. This makes that claim true: the raw text now lands in the
|
|
# same log stream (journal, when run via the systemd timer) as everything
|
|
# else, surviving past the run that produced it.
|
|
#
|
|
# Takes the already-read error TEXT, not the file path. A live run showed
|
|
# categorize_error() correctly matching a specific pattern (so $_ERR had
|
|
# real content at that point) while a second, later read of the same file
|
|
# for this function came back completely empty — every failure that night
|
|
# logged its categorized reason but zero "Raw error:" lines. Whatever causes
|
|
# that (the file is reused across the whole script run and read twice per
|
|
# failure), reading it once and passing the string to both this function and
|
|
# categorize_error() removes the second read entirely, so there's nothing
|
|
# left to race.
|
|
log_raw_error() {
|
|
local raw
|
|
raw="$(printf '%s' "$1" | tr '\n' ' ' | head -c 500)"
|
|
[ -n "$raw" ] && log " Raw error: $raw"
|
|
}
|
|
|
|
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
|
|
declare -a FAILED_SVCS=()
|
|
_ERR="$(mktemp)"
|
|
trap 'rm -f "$_ERR"' EXIT
|
|
START_TS="$(date +%s)"
|
|
BACKUP_COUNT=0
|
|
|
|
for _pf_dest in ${DEST_NAMES:-default}; do
|
|
_pf_var="DEST_${_pf_dest}_REPO"; _pf_repo="${!_pf_var:-}"
|
|
if [ -n "$_pf_repo" ] && [[ "$_pf_repo" != *@*:* ]] && [[ "$_pf_repo" != ssh://* ]]; then
|
|
_pf_dir="$([ -d "$_pf_repo" ] && echo "$_pf_repo" || dirname "$_pf_repo")"
|
|
_pf_avail="$(df -m "$_pf_dir" 2>/dev/null | awk 'NR==2{print $4}')"
|
|
if [ -n "$_pf_avail" ] && [ "$_pf_avail" -lt 512 ]; then
|
|
log "WARNING: Low disk for '$_pf_dest' — ${_pf_avail}MB free at $_pf_repo"
|
|
FAILED_SVCS+=("$_pf_dest: low disk (${_pf_avail}MB free)")
|
|
rc=1
|
|
fi
|
|
fi
|
|
done
|
|
|
|
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" 2>"$_ERR"; then
|
|
log "OK $svc (Minecraft, no downtime)"
|
|
BACKUP_COUNT=$((BACKUP_COUNT+1))
|
|
else
|
|
_err_text="$(cat "$_ERR" 2>/dev/null)"
|
|
_reason="$(categorize_error "$_err_text")"
|
|
log "WARNING: snapshot failed for $svc — $_reason"
|
|
log_raw_error "$_err_text"
|
|
FAILED_SVCS+=("$svc: $_reason")
|
|
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" 2>"$_ERR"; then
|
|
log "OK $svc"
|
|
BACKUP_COUNT=$((BACKUP_COUNT+1))
|
|
else
|
|
_err_text="$(cat "$_ERR" 2>/dev/null)"
|
|
_reason="$(categorize_error "$_err_text")"
|
|
log "WARNING: snapshot failed for $svc — $_reason"
|
|
log_raw_error "$_err_text"
|
|
FAILED_SVCS+=("$svc: $_reason")
|
|
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
|
|
if ! kp_for "$dest" repository sync-to "$REMOTE_TYPE" $REMOTE_ARGS 2>"$_ERR"; then
|
|
_err_text="$(cat "$_ERR" 2>/dev/null)"
|
|
_reason="$(categorize_error "$_err_text")"
|
|
log "WARNING: mirror failed for '$dest' — $_reason"
|
|
log_raw_error "$_err_text"
|
|
FAILED_SVCS+=("mirror[$dest]: $_reason")
|
|
rc=1
|
|
fi
|
|
done
|
|
fi
|
|
|
|
# Additional mirrors (EXTRA_MIRROR_NAMES) — run alongside REMOTE_TYPE above,
|
|
# not instead of it, so a box can mirror to e.g. Backblaze B2 AND directly
|
|
# to a spare over SSH/SFTP at the same time.
|
|
for mirror_name in ${EXTRA_MIRROR_NAMES:-}; do
|
|
_mtype_var="MIRROR_${mirror_name}_TYPE"
|
|
_margs_var="MIRROR_${mirror_name}_ARGS"
|
|
_mtype="${!_mtype_var:-}"
|
|
_margs="${!_margs_var:-}"
|
|
[ -n "$_mtype" ] || continue
|
|
for dest in ${DEST_NAMES:-default}; do
|
|
log "Mirroring '$dest' to '$mirror_name' ($_mtype)..."
|
|
# shellcheck disable=SC2086
|
|
if ! kp_for "$dest" repository sync-to "$_mtype" $_margs 2>"$_ERR"; then
|
|
_err_text="$(cat "$_ERR" 2>/dev/null)"
|
|
_reason="$(categorize_error "$_err_text")"
|
|
log "WARNING: mirror '$mirror_name' failed for '$dest' — $_reason"
|
|
log_raw_error "$_err_text"
|
|
FAILED_SVCS+=("mirror[$mirror_name/$dest]: $_reason")
|
|
rc=1
|
|
fi
|
|
done
|
|
done
|
|
|
|
# ── Keep a spare box's copy of backup.conf + README current ─────────────────
|
|
# Runs after the data itself is backed up (and mirrored, if configured) so a
|
|
# sync never ships config pointing at a repo state that isn't actually there
|
|
# yet. dr_bringup.sh on the spare only needs these two small files — the
|
|
# repo data itself already lives wherever REMOTE_TYPE mirrored it (or is
|
|
# local, if the spare IS that target).
|
|
# Wraps a remote path for use inside a `ssh host "command '...'"` string so
|
|
# it's still safely quoted (spaces etc.) while a leading ~/ stays OUTSIDE
|
|
# the quotes — single-quoting a leading tilde stops the remote shell from
|
|
# expanding it at all, so it goes looking for a literal directory named
|
|
# "~" instead of the actual home directory. Confirmed live: this is exactly
|
|
# what broke `mkdir -p '~/docker/backup'` / `chmod 600 '~/docker/backup/...'`
|
|
# — the DEFAULT DR_SYNC_PATH — while rsync's own transfer step (which has
|
|
# its own tilde-aware remote-path handling, unrelated to shell quoting)
|
|
# succeeded against the exact same path.
|
|
_dr_remote_quote() {
|
|
local p="$1"
|
|
if [[ "$p" == "~/"* ]]; then
|
|
printf "~/'%s'" "${p#\~/}"
|
|
elif [[ "$p" == "~" ]]; then
|
|
printf '~'
|
|
else
|
|
printf "'%s'" "$p"
|
|
fi
|
|
}
|
|
|
|
if [ -n "${DR_SYNC_HOST:-}" ]; then
|
|
_dr_path="${DR_SYNC_PATH:-~/docker/backup}"
|
|
log "Syncing backup.conf + README to spare ($DR_SYNC_HOST:$_dr_path)..."
|
|
_dr_files=("$CONF")
|
|
[ -f "$HERE/README.md" ] && _dr_files+=("$HERE/README.md")
|
|
# rsync instead of scp: modern OpenSSH (9.0+) defaults scp to an
|
|
# SFTP-based transfer, and some remote-side setups (restricted shells,
|
|
# forced commands, older sshd) reject that with an immediate "Connection
|
|
# closed" while plain ssh exec and rsync's own protocol both still work
|
|
# fine over the same connection. Confirmed live: scp failing this way
|
|
# while `ssh "$DR_SYNC_HOST" true` succeeded, rsync doesn't hit it.
|
|
if ssh -o BatchMode=yes -o ConnectTimeout=10 "$DR_SYNC_HOST" "mkdir -p $(_dr_remote_quote "$_dr_path")" 2>"$_ERR" \
|
|
&& rsync -a -e 'ssh -o BatchMode=yes -o ConnectTimeout=10' "${_dr_files[@]}" "$DR_SYNC_HOST:$_dr_path/" 2>>"$_ERR" \
|
|
&& ssh -o BatchMode=yes -o ConnectTimeout=10 "$DR_SYNC_HOST" "chmod 600 $(_dr_remote_quote "$_dr_path/backup.conf")" 2>>"$_ERR"; then
|
|
log "OK spare sync ($DR_SYNC_HOST)"
|
|
else
|
|
_err_text="$(cat "$_ERR" 2>/dev/null)"
|
|
_reason="$(categorize_error "$_err_text")"
|
|
log "WARNING: spare sync failed — $_reason"
|
|
log_raw_error "$_err_text"
|
|
FAILED_SVCS+=("spare-sync: $_reason")
|
|
rc=1
|
|
fi
|
|
fi
|
|
|
|
DURATION=$(( $(date +%s) - START_TS ))
|
|
DURATION_STR="$((DURATION/60))m $((DURATION%60))s"
|
|
|
|
if [ "$rc" -eq 0 ]; then
|
|
log "===== Backup complete — $BACKUP_COUNT service(s) in $DURATION_STR ====="
|
|
ntfy_send "✓ Backup complete" \
|
|
"$HOST: $BACKUP_COUNT service(s) backed up in $DURATION_STR" \
|
|
"low" "white_check_mark"
|
|
else
|
|
log "===== Backup finished WITH WARNINGS — $BACKUP_COUNT/$((BACKUP_COUNT+${#FAILED_SVCS[@]})) succeeded in $DURATION_STR ====="
|
|
_ntfy_msg="$HOST: backup failures (${#FAILED_SVCS[@]}):"
|
|
for _s in "${FAILED_SVCS[@]}"; do _ntfy_msg+=$'\n'"• $_s"; done
|
|
ntfy_send "✗ Backup FAILED" "$_ntfy_msg" "urgent" "rotating_light"
|
|
fi
|
|
exit "$rc"
|