#!/bin/bash # Find backup repositories already on this machine, and check one without # unlocking it. # # Rebuilding a server, the repository is nearly always somewhere obvious: the # install's own backups root if the disk survived, or a drive that was just # plugged in. Making the user type that path from memory — while looking at a # placeholder invented for an example — is asking them to recall the one thing # they came here because they could not. # # NEITHER OF THESE NEEDS THE PASSWORD. A restic repository keeps one file per # snapshot under snapshots/, so the count is a directory listing. Nothing is # decrypted, nothing is opened; the password is still required to read what is # actually IN those snapshots, which is the next step. # The directories a restic repository always has. `config` alone is not enough # — a folder someone named "config" would pass — and requiring the three that # only restic creates keeps a stray directory from being offered as a backup. _restoreRepoLooksReal() { local d="${1%/}" [[ -n "$d" ]] || return 1 runFileOp test -f "$d/config" 2>/dev/null || return 1 runFileOp test -d "$d/snapshots" 2>/dev/null || return 1 runFileOp test -d "$d/keys" 2>/dev/null || return 1 runFileOp test -d "$d/data" 2>/dev/null || return 1 return 0 } # How many snapshots, and when the newest arrived. Both from the directory # listing, so this works on a repository we have no key for. _restoreRepoStats() { local d="${1%/}" local n newest n=$(runFileOp find "$d/snapshots" -maxdepth 1 -type f 2>/dev/null | grep -c .) # Not `ls -t`: a repository with thousands of snapshots would sort them all # to answer one question. newest=$(runFileOp find "$d/snapshots" -maxdepth 1 -type f -printf '%T@\n' 2>/dev/null \ | sort -rn | head -1 | cut -d. -f1) printf '%s\t%s\n' "${n:-0}" "${newest:-}" } # Check one path. Prints JSON. # # restore verify restoreVerifyPath() { local d="${1:-}" if [[ -z "$d" || "$d" != /* ]]; then echo '{"repo":false,"reason":"Give a full path, starting with /."}' return 1 fi if ! runFileOp test -d "$d" 2>/dev/null; then echo '{"repo":false,"reason":"Nothing at that path, or it is not readable from here."}' return 1 fi if ! _restoreRepoLooksReal "$d"; then # The overwhelmingly common near-miss: pointing at the folder that # CONTAINS the repositories rather than at one of them. local inner first="" while IFS= read -r inner; do [[ -z "$inner" ]] && continue if _restoreRepoLooksReal "$inner"; then first="$inner"; break; fi done < <(runFileOp find "$d" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort) if [[ -n "$first" ]]; then printf '{"repo":false,"reason":"That folder holds backups rather than being one. Try %s","suggest":"%s"}\n' \ "$(_lpJsonStr "$first")" "$(_lpJsonStr "$first")" return 1 fi echo '{"repo":false,"reason":"No backup repository there."}' return 1 fi local stats n newest stats=$(_restoreRepoStats "$d") IFS=$'\t' read -r n newest <<< "$stats" # The count and the newest, and no more. A list of bare timestamps was # rendered here for a while, but it could only ever be timestamps — a # snapshot's identity is in the encrypted object — and once Contents # offered a real per-app chooser, the pre-password list was answering a # question the card above it had already answered. jq -nc --arg p "$d" --argjson n "${n:-0}" \ --arg newest "$([[ -n "$newest" ]] && date -d "@$newest" -Iseconds 2>/dev/null || printf '')" \ '{repo: true, path: $p, snapshots: $n, newest: $newest}' return 0 } # Where to look for repositories on this machine. # # Bounded deliberately: named shapes and one level under each mount, never a # walk of the filesystem. A scan that takes a minute on a big disk is a scan # nobody waits for, and the answer is nearly always in one of these places. _restoreScanRoots() { # This install's own backups root — the disk may well have survived. local b="${backup_dir%/}" [[ -n "$b" ]] && runFileOp find "$b" -mindepth 1 -maxdepth 1 -type d 2>/dev/null # Every backup location this install already knows about. if declare -f resticEnabledLocations >/dev/null 2>&1; then local idx p while IFS= read -r idx; do [[ -z "$idx" ]] && continue p=$(backupLocationPath "$idx" 2>/dev/null) [[ -n "$p" ]] && printf '%s\n' "${p%/}" done < <(resticEnabledLocations 2>/dev/null) fi # Mounted filesystems that are not the OS: a plugged-in disk is the other # half of "rebuilding after the system drive died". command -v findmnt >/dev/null 2>&1 || return 0 local sys_dev; sys_dev=$(stat -c '%d' -- / 2>/dev/null) local line target dev while IFS= read -r line; do target="${line#TARGET=\"}"; target="${target%%\"*}" [[ -z "$target" ]] && continue case "$target" in /|/boot|/boot/*|/efi|/proc*|/sys*|/dev*|/run*|/snap*|/var/snap/*|/tmp) continue ;; esac dev=$(stat -c '%d' -- "$target" 2>/dev/null) [[ -n "$dev" && "$dev" == "$sys_dev" ]] && continue printf '%s\n' "$target" runFileOp find "$target" -mindepth 1 -maxdepth 1 -type d 2>/dev/null runFileOp find "$target/libreportal-backups" -mindepth 1 -maxdepth 1 -type d 2>/dev/null done < <(findmnt -Pno TARGET 2>/dev/null) } # Every repository found, as a JSON array. # # restore scan restoreScanLocal() { local -a seen=() local out='[]' d stats n newest iso while IFS= read -r d; do d="${d%/}" [[ -z "$d" ]] && continue # A path can be reached by more than one root — the install's backups # dir is also a registered location — and listing it twice would read # as two different backups. local dup=0 s for s in "${seen[@]}"; do [[ "$s" == "$d" ]] && { dup=1; break; }; done (( dup )) && continue seen+=("$d") _restoreRepoLooksReal "$d" || continue stats=$(_restoreRepoStats "$d") IFS=$'\t' read -r n newest <<< "$stats" iso="" [[ -n "$newest" ]] && iso=$(date -d "@$newest" -Iseconds 2>/dev/null) out=$(jq -c --arg p "$d" --argjson n "${n:-0}" --arg t "$iso" \ '. + [{path: $p, snapshots: $n, newest: $t}]' <<< "$out") done < <(_restoreScanRoots) # Most snapshots first: on a machine with more than one, that is nearly # always the one being rebuilt from. jq -c 'sort_by(-.snapshots)' <<< "$out" return 0 } # Both, published where the WebUI polls for them. restoreScanPublish() { local nonce="${1:-}" local out_dir; out_dir="$(webuiDir)/frontend/data/system" createFolders "quiet" "$sudo_user_name" "$out_dir" local tmp; tmp=$(mktemp) || return 1 jq -nc --argjson found "$(restoreScanLocal)" --arg nonce "$nonce" \ '{found: $found, nonce: $nonce}' > "$tmp" runFileWrite "$out_dir/restore_scan.json" < "$tmp" rm -f "$tmp" return 0 } restoreVerifyPublish() { local path="${1:-}" nonce="${2:-}" local out_dir; out_dir="$(webuiDir)/frontend/data/system" createFolders "quiet" "$sudo_user_name" "$out_dir" local body; body=$(restoreVerifyPath "$path") local tmp; tmp=$(mktemp) || return 1 jq -c --arg nonce "$nonce" '. + {nonce: $nonce}' <<< "$body" > "$tmp" 2>/dev/null \ || printf '{"repo":false,"reason":"unreadable result","nonce":"%s"}\n' "$(_lpJsonStr "$nonce")" > "$tmp" runFileWrite "$out_dir/restore_verify.json" < "$tmp" rm -f "$tmp" return 0 }