LibrePortal/scripts/restore/restore_scan.sh
librelad cf8a4b2c69 Find the backup before asking for its password
The Backup step opened with an empty box and /mnt/usb/libreportal-backups as
the placeholder — a path nobody has, presented as the shape of the answer.
Someone rebuilding a server was being asked to recall from memory the one thing
they came here because they had lost.

Two additions, and the point of both is that neither needs the repository
password. A restic repository keeps one file per snapshot under snapshots/, so
"is there a backup here, and how many" is a directory listing. Nothing is
decrypted — reading what is IN those snapshots is the next step, and that does
need the password.

restore scan looks where a backup actually is: this install's own backups root
(the disk often survives), every location the install already knows about, and
one level under each non-OS mount, a just-plugged-in drive being the other half
of "the system drive died". Bounded to named shapes and maxdepth 1, never a
filesystem walk — a scan nobody waits for is a scan nobody uses. Results are
buttons, most snapshots first, each showing its count and the age of its
newest snapshot; clicking one fills the path in.

restore verify <path> answers the same for a typed path. Its most useful answer
is the near-miss: pointing at the folder that CONTAINS the repositories rather
than at one of them, which it names and offers as a button rather than
explaining the distinction in prose.

A repository is recognised by config plus the snapshots, keys and data
directories together. config alone would match any folder with a file of that
name, and offering a stray directory as someone's backup is worse than finding
nothing.

The placeholder now comes from this machine — the first repository found, or
the install's own backups root — since a placeholder's job is to show the shape
of the answer and only a real one does that. The backups root is in the storage
feed for it.

The found entries are buttons and had to own their geometry: .setup-app-card
carries no layout, it is a bare wrapper elsewhere, so a <button> wearing it
collapsed to one cramped line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 07:04:15 +01:00

185 lines
7.3 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:-}"
}
# 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"
printf '{"repo":true,"path":"%s","snapshots":%s,"newest":"%s"}\n' \
"$(_lpJsonStr "$d")" "${n:-0}" \
"$([[ -n "$newest" ]] && date -d "@$newest" -Iseconds 2>/dev/null || printf '')"
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
}