Three things. The snapshot times now sit under the count. They are the only thing about a snapshot legible without the key — the filename is an opaque hash and everything describing what is inside is in the encrypted object — so they answer "is this the backup I think it is, and did it run when I expect", which is the question someone actually has before typing a password into it. Next is disabled until the backup has been opened, rather than accepting the click and then arguing. validateStep still refuses, for anyone who arrives another way, but the disabled state says "something above me is unfinished" before the click instead of after. It re-enables on a successful read and goes back to disabled the moment the path, type or password changes, since that read is then about a different repository. Error text was rgb(220,53,69) on a 10%-opacity danger background — a mid red on a dark blue panel, legible in theory and squinted at in practice. Lighter text, a firmer border, more line-height. The test asserts perceived brightness rather than an exact colour, so a theme change cannot quietly undo it. One real bug on the way: _adoptSingleResult rebuilt the record by hand, naming four fields, so `times` was dropped and the list came out empty even though the data was right there. It passes the whole record through now — a field lost that way is invisible until something downstream needs it. Not done, and worth stating plainly: these times are not selectable. Picking one would be picking a hash — a restic snapshot is ONE app's data or the settings tree, not a whole machine, and which is which cannot be known until the repository is open. Choosing a point in time to restore from is a real thing to want and belongs on Contents, after unlocking, where the snapshots have names. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
214 lines
8.7 KiB
Bash
214 lines
8.7 KiB
Bash
#!/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:-}"
|
|
}
|
|
|
|
# WHEN each snapshot was written, newest first, as a JSON array of ISO times.
|
|
#
|
|
# The times are the only thing about a snapshot that is legible without the
|
|
# key: the file name is an opaque hash and everything describing what is inside
|
|
# — which host, which app, what it holds — is in the encrypted object. So this
|
|
# answers "is this the backup I think it is, and is it recent", which is what
|
|
# someone is asking before they type a password. It cannot answer "which one do
|
|
# I want to restore", because it does not know what any of them are.
|
|
#
|
|
# Capped: a repository with a year of daily snapshots would otherwise hand the
|
|
# browser several hundred rows nobody scrolls.
|
|
_restoreRepoSnapshotTimes()
|
|
{
|
|
local d="${1%/}" cap="${2:-12}"
|
|
local out='[]' ts iso
|
|
while IFS= read -r ts; do
|
|
[[ -z "$ts" ]] && continue
|
|
iso=$(date -d "@${ts%%.*}" -Iseconds 2>/dev/null) || continue
|
|
out=$(jq -c --arg t "$iso" '. + [$t]' <<< "$out")
|
|
done < <(runFileOp find "$d/snapshots" -maxdepth 1 -type f -printf '%T@\n' 2>/dev/null \
|
|
| sort -rn | head -n "$cap")
|
|
printf '%s' "$out"
|
|
}
|
|
|
|
# Check one path. Prints JSON.
|
|
#
|
|
# restore verify <path>
|
|
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"
|
|
jq -nc --arg p "$d" --argjson n "${n:-0}" \
|
|
--arg newest "$([[ -n "$newest" ]] && date -d "@$newest" -Iseconds 2>/dev/null || printf '')" \
|
|
--argjson times "$(_restoreRepoSnapshotTimes "$d")" \
|
|
'{repo: true, path: $p, snapshots: $n, newest: $newest, times: $times}'
|
|
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)
|
|
# The times come along too: the found card and the Check result show
|
|
# the same thing, and one of them arriving without them would make the
|
|
# list appear or vanish depending on how the repository was reached.
|
|
out=$(jq -c --arg p "$d" --argjson n "${n:-0}" --arg t "$iso" \
|
|
--argjson times "$(_restoreRepoSnapshotTimes "$d")" \
|
|
'. + [{path: $p, snapshots: $n, newest: $t, times: $times}]' <<< "$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
|
|
}
|