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>
185 lines
7.3 KiB
Bash
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
|
|
}
|