#!/usr/bin/env bash set -euo pipefail CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/docsync" STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/docsync" INCLUDE_FILE="$CONFIG_DIR/include" EXCLUDE_FILE="$CONFIG_DIR/exclude" # The repository is reached over SFTP. $CONFIG_DIR/config sets SSH_HOST to a # Host in ~/.ssh/config that logs in without a prompt (a key, BatchMode-safe), # and may set REPO_PATH, the repository's path on that host. The password # file holds the repository's password, kept in a file so the timer never # waits on a prompt; restic's other keys (a paper recovery passphrase, say) # still open the repository as usual. SSH_HOST= REPO_PATH=repo # shellcheck source=/dev/null [[ -f "$CONFIG_DIR/config" ]] && . "$CONFIG_DIR/config" export RESTIC_REPOSITORY="sftp:$SSH_HOST:$REPO_PATH" export RESTIC_PASSWORD_FILE="$CONFIG_DIR/restic-password" # Every docsync snapshot carries this tag, and snapshots are grouped by host # and tag rather than by path list, so adding or dropping a path continues the # same history (and retention) instead of starting a new one. TAG=docsync GROUP_BY=host,tags # Retention for 'docsync forget': every hourly snapshot for two days, then one # a day for a month, one a week for a year, and one a month for good. KEEP=(--keep-hourly 48 --keep-daily 30 --keep-weekly 52 --keep-monthly unlimited) # How much of the stored data 'docsync check' downloads and verifies per run. CHECK_SUBSET=5% # Desktop notifications (only when run by a systemd timer): a failure while the # server is reachable is worth hearing about soon, being offline only once no # backup has landed for a day. At most one notification per window either way. ERROR_NOTIFY_EVERY=$((6 * 3600)) OFFLINE_NOTIFY_AFTER=$((24 * 3600)) usage() { cat <, and optionally REPO_PATH= (default: repo) restic-password the repository's password, mode 600 include one path per line, relative to ~ unless it starts with / exclude restic exclude patterns; a leading ~/ means your home Blank lines and lines starting with # are ignored in include and exclude. Create the repository once with: docsync restic init The systemd units beside this script run backup hourly, forget weekly and check monthly; they expect it at ~/.local/bin/docsync. State for other tools (Emacs) is in $STATE_DIR: last-success epoch seconds of the last backup that saved a snapshot last-result "EPOCH STATUS MESSAGE", STATUS one of ok, warning, offline, error missing include paths that didn't exist at the last backup manifest every file in the latest snapshot as TYPE SIZE MTIME PATH, tab-separated (jq @tsv escaping), after a "# snapshot ID TIME" line; Emacs compares it with the disk to mark what's changed EOF } die() { echo "docsync: $*" >&2; exit 1; } now() { date +%s; } # A list file's entries, without comments, blank lines or surrounding space. list_lines() { [[ -f "$1" ]] || return 0 sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^#/d' -e '/^$/d' "$1" } include_paths() { local p list_lines "$INCLUDE_FILE" | while IFS= read -r p; do p="${p#\~/}" [[ "$p" == /* ]] || p="$HOME/$p" [[ "$p" == / ]] || p="${p%/}" printf '%s\n' "$p" done } exclude_patterns() { local p list_lines "$EXCLUDE_FILE" | while IFS= read -r p; do [[ "$p" == "~/"* ]] && p="$HOME/${p#\~/}" printf '%s\n' "$p" done } # ---------------------------------------------------------------- state put_state() { # NAME LINE... if (( $# > 1 )); then printf '%s\n' "${@:2}"; fi > "$STATE_DIR/$1.tmp" mv "$STATE_DIR/$1.tmp" "$STATE_DIR/$1" } get_state() { # NAME DEFAULT if [[ -s "$STATE_DIR/$1" ]]; then head -n1 "$STATE_DIR/$1"; else printf '%s\n' "$2"; fi } notify() { # WINDOW URGENCY MESSAGE [[ -n "${INVOCATION_ID:-}" ]] || return 0 local last; last=$(get_state notified 0) (( $(now) - last >= $1 )) || return 0 notify-send -a docsync -u "$2" docsync "$3" 2>/dev/null || true put_state notified "$(now)" } # A run that dies mid-operation (the server dropping off during a prune, say) # leaves its lock behind, and restic never clears one by itself: every later # run would wait out --retry-lock and fail. Without --remove-all, unlock # removes only stale locks, ones whose process has exited or that haven't been # refreshed for 30 minutes (a running restic refreshes its lock every 5). unlock_stale() { restic unlock || true } # A cheap "is the server reachable" probe. With an SFTP-only account, whose # forced command answers any login with an SFTP server that exits at EOF, this # still returns as soon as the connection is up. reachable() { ssh -o BatchMode=yes "$SSH_HOST" true /dev/null 2>&1 } # Not a failure (a laptop's network comes and goes, and the next hourly run # tries again), so only worth a notification once no backup has landed for a day. backup_offline() { put_state last-result "$(now) offline can't reach $SSH_HOST" if (( $(now) - $(get_state last-success 0) >= OFFLINE_NOTIFY_AFTER )); then notify "$OFFLINE_NOTIFY_AFTER" normal "No backup for over a day: can't reach $SSH_HOST." fi echo "docsync: can't reach $SSH_HOST, skipping this backup" >&2 exit 0 } ago() { # EPOCH local s=$(( $(now) - $1 )) if (( s < 90 )); then echo "just now" elif (( s < 5400 )); then echo "$(( s / 60 )) min ago" elif (( s < 172800 )); then echo "$(( s / 3600 )) h ago" else echo "$(( s / 86400 )) days ago" fi } # ---------------------------------------------------------------- commands # Every file in the latest snapshot with its size and modification time, so # Emacs can tell what has changed since without asking the repository. A # failure keeps the old manifest: Emacs then shows more as changed than has, # never less. cmd_manifest() { mkdir -p "$STATE_DIR" local out="$STATE_DIR/manifest" if restic ls --json --tag "$TAG" latest 2>/dev/null | jq -r ' if .struct_type == "snapshot" then "# snapshot \(.id) \(.time)" elif .struct_type == "node" then [.type, (.size // 0), .mtime, .path] | @tsv else empty end' > "$out.tmp"; then mv "$out.tmp" "$out" else rm -f "$out.tmp" echo "docsync: couldn't list the latest snapshot; the manifest is stale" >&2 return 1 fi } cmd_backup() { mkdir -p "$STATE_DIR" exec 9>"$STATE_DIR/lock" flock -n 9 || { echo "docsync: a backup is already running" >&2; exit 0; } [[ -f "$RESTIC_PASSWORD_FILE" ]] || die "no password file; put the repository's password in $RESTIC_PASSWORD_FILE (mode 600)" [[ -f "$INCLUDE_FILE" ]] || die "nothing to back up: $INCLUDE_FILE is missing" # Global, not local: the EXIT trap runs after this function has returned. tmp=$(mktemp -d) trap 'rm -rf "$tmp"' EXIT local -a present=() missing=() local p while IFS= read -r p; do if [[ -e "$p" || -L "$p" ]]; then present+=("$p"); else missing+=("$p"); fi done < <(include_paths) put_state missing "${missing[@]}" for p in "${missing[@]}"; do echo "docsync: listed but missing: $p" >&2; done if (( ${#present[@]} == 0 )); then put_state last-result "$(now) error none of the listed paths exist" notify "$ERROR_NOTIFY_EVERY" critical "Backup failed: none of the listed paths exist." die "none of the paths in $INCLUDE_FILE exist" fi printf '%s\n' "${present[@]}" > "$tmp/include" exclude_patterns > "$tmp/exclude" reachable || backup_offline unlock_stale local rc=0 restic backup \ --files-from-verbatim "$tmp/include" \ --exclude-file "$tmp/exclude" \ --exclude-caches \ --tag "$TAG" --group-by "$GROUP_BY" \ --skip-if-unchanged \ --retry-lock 1h \ 2>"$tmp/err" || rc=$? # Echoed by bash rather than cat: journald files a line under its unit by # looking up the writing process, and cat has exited by then, so restic's # error would be missing from journalctl --user -u docsync-backup. [[ ! -s "$tmp/err" ]] || printf '%s\n' "$(<"$tmp/err")" >&2 local err; err=$(grep -v '^[[:space:]]*$' "$tmp/err" | tail -n1 || true) case $rc in 0|3) put_state last-success "$(now)" rm -f "$STATE_DIR/notified" cmd_manifest || true ;; esac case $rc in 0) if (( ${#missing[@]} )); then put_state last-result "$(now) warning ${#missing[@]} listed path(s) missing" notify "$ERROR_NOTIFY_EVERY" normal "Backed up, but ${#missing[@]} listed path(s) no longer exist." else put_state last-result "$(now) ok" fi ;; 3) put_state last-result "$(now) warning some files couldn't be read: $err" notify "$ERROR_NOTIFY_EVERY" normal "Backed up, but some files couldn't be read." ;; *) # Losing the server partway through is being offline, not an error. reachable || backup_offline put_state last-result "$(now) error ${err:-restic exited with $rc}" notify "$ERROR_NOTIFY_EVERY" critical "Backup failed: ${err:-restic exited with $rc}" exit "$rc" ;; esac } # forget and check: on failure under a timer, say so, unless the server dropped # off; the next scheduled run clears the stale lock that leaves and starts over. maintenance() { # WHAT RESTIC-ARGS... local what=$1; shift unlock_stale if ! restic "$@" --retry-lock 1h; then [[ -n "${INVOCATION_ID:-}" ]] && reachable && notify-send -a docsync -u critical docsync "docsync $what failed; see journalctl --user -u docsync-$what" 2>/dev/null exit 1 fi } cmd_forget() { maintenance forget forget --tag "$TAG" --group-by "$GROUP_BY" "${KEEP[@]}" --prune } cmd_check() { maintenance check check --read-data-subset "$CHECK_SUBSET" } cmd_status() { local last result last=$(get_state last-success "") result=$(get_state last-result "") if [[ -n "$last" ]]; then echo "Last backup: $(date -d "@$last" '+%F %R') ($(ago "$last"))" else echo "Last backup: never" fi if [[ -n "$result" ]]; then read -r when status msg <<<"$result" echo "Last run: $(date -d "@$when" '+%F %R') $status${msg:+: $msg}" fi if [[ -s "$STATE_DIR/missing" ]]; then echo "Missing:"; sed 's/^/ /' "$STATE_DIR/missing" fi echo restic snapshots --tag "$TAG" --group-by "$GROUP_BY" --latest 5 --compact || echo "(couldn't list snapshots; is $SSH_HOST reachable?)" } cmd_paths() { local p echo "Include:" include_paths | while IFS= read -r p; do if [[ -e "$p" || -L "$p" ]]; then echo " $p"; else echo " $p (missing)"; fi done echo "Exclude:" exclude_patterns | sed 's/^/ /' } case "${1:-}" in backup|forget|check|status|manifest|restic) [[ -n "$SSH_HOST" ]] || die "set SSH_HOST in $CONFIG_DIR/config" ;; esac case "${1:-}" in backup) cmd_backup ;; forget) cmd_forget ;; check) cmd_check ;; status) cmd_status ;; paths) cmd_paths ;; manifest) cmd_manifest ;; restic) shift; exec restic "$@" ;; -h|--help|help|"") usage ;; *) usage >&2; exit 2 ;; esac