#!/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}"