212 lines
8.7 KiB
Bash
Executable File
212 lines
8.7 KiB
Bash
Executable File
#!/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}"
|