diff --git a/.local/bin/restic-backup b/.local/bin/restic-backup new file mode 100755 index 0000000..42b60de --- /dev/null +++ b/.local/bin/restic-backup @@ -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}"