restic-backup: add backup script

This commit is contained in:
2026-06-02 12:57:03 +02:00
parent 651450df7c
commit 0cae5063bc
+211
View File
@@ -0,0 +1,211 @@
#!/bin/bash
# restic-backup — daily restic backup driver, meant to be polled every 15 min by cron.
#
# restic-backup run the periodic check (prompt + backup if a daily backup is due)
# restic-backup run same as above (explicit)
# restic-backup status print the dwmblocks status symbol (running -> symbol, idle -> empty line)
# restic-backup force skip the "is it due?" check and back up now (still prompts)
#
# dwmblocks integration: add a block whose command is `restic-backup status` on signal
# RTMIN+14, e.g. in ~/src/dwmblocks/config.h:
# {"restic-backup status", 0, 14},
# The script refreshes that block itself via `pkill -RTMIN+14 dwmblocks`.
set -u
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
REPO="sftp:root@bocken.org:/backups/t14"
HOST="bocken.org" # backup server, checked for reachability before we prompt
PORT=22 # sftp/ssh port
INCLUDE_FILE="$HOME/.config/restic/include_files"
EXCLUDE_FILE="$HOME/.config/restic/exclude_files"
SIGNAL=14 # dwmblocks RTMIN+SIGNAL (unused in current config.h)
SYMBOL=$'' # status symbol shown while a backup is running (nf database; edit to taste)
LOW_BATTERY=30 # %, below this on battery we postpone and notify
STATE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/restic-backup"
LAST_SUCCESS="$STATE_DIR/last_success" # holds YYYY-MM-DD of last successful backup
RUNNING_FLAG="$STATE_DIR/running" # present only while restic is running
LOCK_FILE="$STATE_DIR/lock" # flock target, serialises cron invocations
SNOOZE_FILE="$STATE_DIR/snooze" # epoch until which we must not re-prompt
LOG_FILE="$STATE_DIR/backup.log"
mkdir -p "$STATE_DIR"
# Repository password comes from pass(1) at "Misc/restic", supplied to restic
# below via --password-command. (pass -> gpg needs the user's gpg-agent, which
# the graphical-env import / DISPLAY recovery below makes reachable from cron.)
PASS_CMD="pass show Misc/restic"
export PATH="$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin:$PATH"
# ---------------------------------------------------------------------------
# Status subcommand — used by dwmblocks. Must be cheap and side-effect free.
# ---------------------------------------------------------------------------
if [ "${1:-run}" = "status" ]; then
if [ -f "$RUNNING_FLAG" ]; then
printf '%s\n' "$SYMBOL"
else
printf '\n'
fi
exit 0
fi
# ---------------------------------------------------------------------------
# Everything below runs from cron, which has no graphical/session environment.
# Recover DISPLAY / XAUTHORITY / DBUS so dmenu and notify-send work.
# ---------------------------------------------------------------------------
import_graphical_env() {
local pid var val
# Find a process that belongs to our graphical session.
pid=$(pgrep -u "$(id -u)" -nx dwm 2>/dev/null) \
|| pid=$(pgrep -u "$(id -u)" -nx dwmblocks 2>/dev/null) \
|| pid=$(pgrep -u "$(id -u)" -nx Xorg 2>/dev/null)
[ -n "$pid" ] && [ -r "/proc/$pid/environ" ] || return 0
for var in DISPLAY XAUTHORITY DBUS_SESSION_BUS_ADDRESS; do
if [ -z "${!var:-}" ]; then
val=$(tr '\0' '\n' < "/proc/$pid/environ" | grep "^$var=" | head -n1 | cut -d= -f2-)
[ -n "$val" ] && export "$var=$val"
fi
done
}
import_graphical_env
export DISPLAY="${DISPLAY:-:0}"
export XAUTHORITY="${XAUTHORITY:-$HOME/.Xauthority}"
notify() { notify-send -a "restic backup" "$@"; }
refresh_bar() { pkill "-RTMIN+$SIGNAL" dwmblocks 2>/dev/null; }
log() { printf '%s %s\n' "$(date '+%F %T')" "$*" >> "$LOG_FILE"; }
# ---------------------------------------------------------------------------
# Battery check. Returns 0 = ok to back up, 1 = postpone (low battery on AC off).
# ---------------------------------------------------------------------------
battery_ok() {
local ac on_ac=0 cap=100 bat
for ac in /sys/class/power_supply/A{C,DP}*; do
[ -r "$ac/online" ] && on_ac=$(cat "$ac/online") && break
done
[ "$on_ac" = "1" ] && return 0 # plugged in -> always fine
for bat in /sys/class/power_supply/BAT*; do
[ -r "$bat/capacity" ] && cap=$(cat "$bat/capacity") && break
done
[ "$cap" -ge "$LOW_BATTERY" ] && return 0
return 1
}
# ---------------------------------------------------------------------------
# Is the backup server reachable? TCP connect to the sftp port, no extra deps.
# ---------------------------------------------------------------------------
host_reachable() {
timeout 5 bash -c "exec 3<>/dev/tcp/$HOST/$PORT" 2>/dev/null
}
# ---------------------------------------------------------------------------
# Is a daily backup due? Due unless we already succeeded today.
# ---------------------------------------------------------------------------
backup_due() {
[ -f "$LAST_SUCCESS" ] || return 0
[ "$(cat "$LAST_SUCCESS")" != "$(date '+%F')" ]
}
# Currently snoozed by an earlier decline? (cleared automatically once it lapses)
snoozed() {
[ -f "$SNOOZE_FILE" ] || return 1
[ "$(date '+%s')" -lt "$(cat "$SNOOZE_FILE")" ]
}
# ---------------------------------------------------------------------------
# Run restic. Sets the running flag, repaints the bar, restores on exit.
# ---------------------------------------------------------------------------
do_backup() {
: > "$RUNNING_FLAG"
refresh_bar
# Always clear the flag and repaint, no matter how we exit.
trap 'rm -f "$RUNNING_FLAG"; refresh_bar' EXIT
log "backup started"
# restic command uses paths relative to $HOME in the original invocation.
cd "$HOME" || exit 1
if restic backup -r "$REPO" \
--password-command "$PASS_CMD" \
--files-from "$INCLUDE_FILE" \
--exclude-file "$EXCLUDE_FILE" >> "$LOG_FILE" 2>&1; then
date '+%F' > "$LAST_SUCCESS"
rm -f "$SNOOZE_FILE"
log "backup finished OK"
notify "✅ Backup complete" "restic snapshot stored on bocken.org:/backups/t14"
else
log "backup FAILED (see $LOG_FILE)"
notify -u critical "❌ Backup failed" "restic exited non-zero. See $LOG_FILE"
fi
}
# ---------------------------------------------------------------------------
# Main: the periodic (cron) entrypoint.
# ---------------------------------------------------------------------------
main() {
local force="${1:-}"
# Only ever decide whether a backup is due if none is in progress / pending.
# flock -n serialises the 15-minute cron invocations: while a prompt is open
# or restic is running, later invocations just exit instead of stacking.
exec 9>"$LOCK_FILE"
flock -n 9 || exit 0
if [ "$force" != "force" ] && ! backup_due; then
exit 0
fi
# Honour a snooze the user picked after a previous decline ("force" overrides).
if [ "$force" != "force" ] && snoozed; then
exit 0
fi
if ! battery_ok; then
notify -u critical "🔌 Backup postponed" \
"A daily backup is due but the battery is low. Plug in the laptop to start it."
log "postponed: low battery"
exit 0
fi
# Don't pester the user if the server can't be reached; just retry next tick.
if ! host_reachable; then
log "postponed: $HOST:$PORT unreachable"
exit 0
fi
# Ask before doing the (potentially long, bandwidth-heavy) backup.
# `prompt` (~/.local/bin) runs its 2nd arg only when "Yes" is picked; we give
# it a no-op so its exit status reports the choice while do_backup stays here
# in the parent, under the flock we already hold.
if prompt "Daily restic backup is due — run it now?" true; then
do_backup
else
# Declined: ask how long to wait before prompting again.
local sel until
sel=$(printf 'in 15 minutes\nin 1 hour\nin 4 hours\nnext day' \
| dmenu -i -p "Backup postponed — re-attempt when?")
case "$sel" in
"in 15 minutes") until=$(date -d '+15 minutes' '+%s') ;;
"in 1 hour") until=$(date -d '+1 hour' '+%s') ;;
"in 4 hours") until=$(date -d '+4 hours' '+%s') ;;
"next day") until=$(date -d 'tomorrow 00:00' '+%s') ;;
*) until=0 ;; # dismissed -> no snooze, retry next tick
esac
if [ "$until" -gt 0 ]; then
printf '%s\n' "$until" > "$SNOOZE_FILE"
log "user declined; snoozed $sel (until $(date -d "@$until" '+%F %T'))"
else
rm -f "$SNOOZE_FILE"
log "user declined; no snooze chosen, retry next tick"
fi
fi
}
main "${1:-run}"