LibrePortal/scripts/restore/restore_scan.sh
librelad 94683db240 Backup step: snapshot times, a disabled Next, and readable errors
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>
2026-08-29 08:43:02 +01:00

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
}