The wizard's first question is now "is this a new server, or a replacement for
one?", which §2 of the roadmap described and nothing implemented. Start asks,
and the answer selects one of two disjoint step sets:
new Start > Experience > Identity > Domains > Storage > Backups
> Import > Recommended > (Metrics)
restore Start > Source > Contents > Rebuild
Disjoint deliberately. A restore is never asked for an install name, domains or
an app list — the backup answers all three, and asking invites someone to type
an answer that is about to be written over. The test asserts non-overlap in
both directions, not just that the restore steps appear.
Source collects the repository the way the Backup page does, minus everything
that only means something for a place you write TO: no retention, no schedule,
no enable toggle. The password leaves through the one-shot secret:<ref> channel
and is cleared from the DOM, and the test asserts the value never appears in
the payload — that payload reaches a task command line, and tasks are recorded
world-readable.
Contents is the reconciliation, rendered: apps with sizes, and each domain with
a verdict, checked through the same /api/setup/dns-check the Domains step uses
rather than adding a second way to ask. Plus the offer to leave the strays out
until DNS is repointed.
Rebuild runs `restore rebuild`: settings first (they carry every other
repository's credentials), then domains, then apps with no explicit list so
bulk discovers and re-preflights them itself.
Inserting Start shifted every step index by one. validateStep was a chain of
idx === 1 … idx === 6, carrying a comment that already explained which earlier
insertions had moved them — it is keyed on the step name now.
lp-storage-step-test had the same pin and did not survive: it called
validateStep(3) for Storage, which had become Domains, and reported that
nothing blocked. That reads exactly like validation being broken. Tests look
their step up by name now too.
Also: locationRemove's fix means a failed connect can finally clean up after
itself, so a wrong password no longer leaves a dead destination behind on every
retry.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
285 lines
11 KiB
Bash
285 lines
11 KiB
Bash
#!/bin/bash
|
|
|
|
# Read a backup repository and report what a restore from it would bring —
|
|
# without writing anything.
|
|
#
|
|
# This is what lets a person decide before committing, in the WebUI as well as
|
|
# the installer: which machine's backups are in there, which apps, how big, how
|
|
# recent, and which domains would come across. Every answer is read out of
|
|
# snapshots; nothing is restored, nothing on this machine is touched.
|
|
#
|
|
# The domains matter most and are the least obvious to get at. They live in the
|
|
# system-config snapshot, in network/network_domains — so they can be pulled
|
|
# with engineDumpFile the same way the preflight pulls an app's manifest, long
|
|
# before anything is adopted. Knowing "this backup will hand you six domains,
|
|
# four of which point somewhere else" is the difference between a rebuild and a
|
|
# surprise.
|
|
#
|
|
# Emits one JSON object, for the WebUI to render and for a person to read.
|
|
|
|
# The domains recorded in the repository's system-config snapshot.
|
|
# Silent on failure: an older backup may not carry the file, and that is not a
|
|
# reason to refuse an inspection.
|
|
restoreInspectDomains()
|
|
{
|
|
local idx="$1" host="$2"
|
|
|
|
# There is no engineSystemSnapshotLatestId — only the JSON listing — so
|
|
# take the last entry, which is the newest: restic returns snapshots
|
|
# oldest-first.
|
|
local snap
|
|
snap=$(engineSystemSnapshotsJson "$idx" "$host" 2>/dev/null \
|
|
| grep -o '"short_id":"[^"]*"' | tail -1 | cut -d'"' -f4)
|
|
[[ -n "$snap" ]] || return 0
|
|
|
|
# The snapshot is the config tree, taken from this install's configs dir, so
|
|
# the file sits at that absolute path inside it. Ask for a few plausible
|
|
# roots rather than assuming one: the source machine's config root is
|
|
# exactly the thing that differs between installs.
|
|
local base out=""
|
|
for base in "${configs_dir%/}" "/libreportal-system/configs" "/docker/configs"; do
|
|
out=$(engineDumpFile "$idx" "$snap" "$base/network/network_domains" 2>/dev/null)
|
|
[[ -n "$out" ]] && break
|
|
done
|
|
[[ -n "$out" ]] || return 0
|
|
|
|
local line v
|
|
while IFS= read -r line; do
|
|
[[ "$line" =~ ^CFG_DOMAIN_[0-9]+= ]] || continue
|
|
v="${line#*=}"; v="${v%%#*}"
|
|
v="${v#"${v%%[![:space:]]*}"}"; v="${v%"${v##*[![:space:]]}"}"
|
|
v="${v%\"}"; v="${v#\"}"; v="${v%\'}"; v="${v#\'}"
|
|
v="${v#"${v%%[![:space:]]*}"}"; v="${v%"${v##*[![:space:]]}"}"
|
|
[[ -n "$v" ]] && printf '%s\n' "$v"
|
|
done <<< "$out"
|
|
}
|
|
|
|
# Inspect a repository. Prints JSON on stdout.
|
|
#
|
|
# restore inspect <location-idx> [host]
|
|
#
|
|
# With no host it reports every host it found and picks the one with the most
|
|
# apps as the suggestion — a repository that has been pointed at two machines
|
|
# is a normal thing to end up with, and guessing silently is not.
|
|
restoreInspect()
|
|
{
|
|
local idx="${1:-}" want_host="${2:-}"
|
|
if [[ -z "$idx" ]]; then
|
|
echo '{"error":"a backup location is required"}'
|
|
return 1
|
|
fi
|
|
|
|
local snaps
|
|
snaps=$(restoreFirstRunDiscover "$idx" 2>/dev/null)
|
|
if [[ -z "$snaps" || "$snaps" == "null" ]]; then
|
|
# The single most common cause by a distance, and worth saying plainly
|
|
# rather than as "discovery failed".
|
|
echo '{"error":"Could not read that repository — wrong password, or not a LibrePortal backup."}'
|
|
return 1
|
|
fi
|
|
|
|
local -a hosts=()
|
|
local h
|
|
while IFS= read -r h; do [[ -n "$h" ]] && hosts+=("$h"); done \
|
|
< <(printf '%s' "$snaps" | grep -o '"hostname":"[^"]*"' | cut -d'"' -f4 | sort -u)
|
|
|
|
if (( ${#hosts[@]} == 0 )); then
|
|
echo '{"error":"No LibrePortal backups found in that repository."}'
|
|
return 1
|
|
fi
|
|
|
|
local host="$want_host"
|
|
if [[ -z "$host" ]]; then
|
|
# The one with the most apps, not simply the first: a repository often
|
|
# carries a stray snapshot from a machine that was only ever tested.
|
|
local best="" best_n=-1 n
|
|
for h in "${hosts[@]}"; do
|
|
n=$(migrateDiscoverApps "$h" "$idx" 2>/dev/null | grep -c .)
|
|
if (( n > best_n )); then best_n=$n; best="$h"; fi
|
|
done
|
|
host="$best"
|
|
fi
|
|
|
|
local -a apps=()
|
|
local a
|
|
while IFS= read -r a; do [[ -n "$a" ]] && apps+=("$a"); done \
|
|
< <(migrateDiscoverApps "$host" "$idx" 2>/dev/null)
|
|
|
|
# --- assemble ---
|
|
local out='{'
|
|
out+='"host":"'$(_lpJsonStr "$host")'",'
|
|
|
|
out+='"hosts":['
|
|
local first=1
|
|
for h in "${hosts[@]}"; do
|
|
[[ $first -eq 0 ]] && out+=','
|
|
out+='"'$(_lpJsonStr "$h")'"'; first=0
|
|
done
|
|
out+='],'
|
|
|
|
out+='"apps":['
|
|
first=1
|
|
local size_b size_h
|
|
for a in "${apps[@]}"; do
|
|
[[ $first -eq 0 ]] && out+=','
|
|
# Size and date come from the app's own manifest where there is one.
|
|
# An older backup without a manifest still lists — it just has less to
|
|
# say about itself, which is better than being left out of the list.
|
|
size_b=$(restorePreflightManifest "$idx" "$a" "$host" 2>/dev/null \
|
|
| tr -d ' \n\t' | grep -o '"size_bytes":[0-9]*' | cut -d: -f2)
|
|
size_h=""
|
|
[[ -n "$size_b" ]] && size_h=$(_restorePfSize "$size_b")
|
|
out+='{"name":"'$(_lpJsonStr "$a")'","size":"'$(_lpJsonStr "$size_h")'"}'
|
|
first=0
|
|
done
|
|
out+='],'
|
|
|
|
out+='"domains":['
|
|
first=1
|
|
local d
|
|
while IFS= read -r d; do
|
|
[[ -z "$d" ]] && continue
|
|
[[ $first -eq 0 ]] && out+=','
|
|
out+='"'$(_lpJsonStr "$d")'"'; first=0
|
|
done < <(restoreInspectDomains "$idx" "$host")
|
|
out+=']}'
|
|
|
|
printf '%s\n' "$out"
|
|
return 0
|
|
}
|
|
|
|
# Connect a repository described by a base64 JSON payload, then inspect it.
|
|
#
|
|
# restoreConnectInspect <base64-json>
|
|
#
|
|
# Payload: {"location":{"name","type","path","uri","ssh_*","password_ref",…},
|
|
# "host":"optional"}
|
|
#
|
|
# The password arrives as a REFERENCE, never a value. This payload reaches a
|
|
# task command line and tasks are recorded world-readable, so a secret
|
|
# travelling as itself would be readable by any local account —
|
|
# webuiSecretResolve redeems it here, once, at the moment of the write. Same
|
|
# contract as the wizard's backup destinations.
|
|
#
|
|
# Deliberately does NOT call engineInitLocation. Every other path that creates
|
|
# a location initialises it, because it is about to write backups there. This
|
|
# one is pointed at a repository that already exists and is only going to be
|
|
# read; initialising is at best a no-op and at worst the wrong answer to
|
|
# "that path is empty".
|
|
restoreConnectInspect()
|
|
{
|
|
local b64="${1:-}"
|
|
if [[ -z "$b64" ]]; then
|
|
echo '{"error":"no payload"}'
|
|
return 1
|
|
fi
|
|
|
|
local payload
|
|
payload=$(printf '%s' "$b64" | base64 -d 2>/dev/null)
|
|
if [[ -z "$payload" ]]; then
|
|
echo '{"error":"payload could not be decoded"}'
|
|
return 1
|
|
fi
|
|
|
|
local loc; loc=$(jq -c '.location // {}' <<< "$payload" 2>/dev/null)
|
|
[[ -n "$loc" && "$loc" != "{}" ]] || { echo '{"error":"no backup location in the payload"}'; return 1; }
|
|
|
|
local name type idx
|
|
name=$(jq -r '.name // "restore-source"' <<< "$loc")
|
|
type=$(jq -r '.type // "local"' <<< "$loc")
|
|
|
|
idx=$(locationAdd "$name" "$type" 2>/dev/null | tail -1)
|
|
if [[ ! "$idx" =~ ^[0-9]+$ ]]; then
|
|
echo '{"error":"Could not create a backup location to read from."}'
|
|
return 1
|
|
fi
|
|
|
|
local cfg; cfg=$(backupLocationConfig "$idx")
|
|
if [[ ! -f "$cfg" ]]; then
|
|
echo '{"error":"The backup location was created but has no config file."}'
|
|
return 1
|
|
fi
|
|
|
|
local v
|
|
v=$(jq -r '.path // ""' <<< "$loc")
|
|
if [[ -n "$v" ]]; then
|
|
updateConfigOption "CFG_BACKUP_LOC_${idx}_PATH_MODE" "custom" "$cfg" >/dev/null
|
|
updateConfigOption "CFG_BACKUP_LOC_${idx}_PATH" "$v" "$cfg" >/dev/null
|
|
fi
|
|
v=$(jq -r '.uri // ""' <<< "$loc")
|
|
[[ -n "$v" ]] && updateConfigOption "CFG_BACKUP_LOC_${idx}_URI" "$v" "$cfg" >/dev/null
|
|
|
|
local k
|
|
for k in ssh_user ssh_host ssh_port ssh_path s3_bucket s3_key_id b2_account_id; do
|
|
v=$(jq -r --arg k "$k" '.[$k] // ""' <<< "$loc")
|
|
[[ -n "$v" ]] && updateConfigOption "CFG_BACKUP_LOC_${idx}_${k^^}" "$v" "$cfg" >/dev/null
|
|
done
|
|
|
|
# Every secret in the payload, redeemed at the point of use.
|
|
local ref pw
|
|
for k in password ssh_pass s3_key b2_key connect_token; do
|
|
ref=$(jq -r --arg k "${k}_ref" '.[$k] // ""' <<< "$loc")
|
|
[[ -n "$ref" ]] || continue
|
|
if pw=$(webuiSecretResolve "$ref" 2>/dev/null) && [[ -n "$pw" ]]; then
|
|
updateConfigOption "CFG_BACKUP_LOC_${idx}_${k^^}" "$pw" "$cfg" >/dev/null
|
|
pw=""
|
|
fi
|
|
done
|
|
|
|
updateConfigOption "CFG_BACKUP_LOC_${idx}_ENABLED" "true" "$cfg" >/dev/null
|
|
# The credentials just landed, so close the directory to everything that is
|
|
# not LibrePortal before anything else runs.
|
|
runOwnership config-secure >/dev/null 2>&1 || true
|
|
source "$cfg" 2>/dev/null
|
|
|
|
local host; host=$(jq -r '.host // ""' <<< "$payload")
|
|
local out; out=$(restoreInspect "$idx" "$host")
|
|
|
|
# The caller needs the index to restore from later, and it is not otherwise
|
|
# discoverable from the WebUI without guessing.
|
|
if grep -q '"error"' <<< "$out"; then
|
|
# Remove the location we just made. A wrong password is the ordinary
|
|
# case here and the user will simply try again — without this, every
|
|
# retry leaves another half-configured destination behind, and after a
|
|
# few attempts the Backup page lists a column of identical dead
|
|
# entries the user then has to clean up by hand.
|
|
locationRemove "$idx" >/dev/null 2>&1 || true
|
|
printf '%s\n' "$out"
|
|
return 1
|
|
fi
|
|
# Spliced with jq rather than by trimming a closing brace: string surgery
|
|
# on JSON is how you ship something that parses on your machine and not on
|
|
# the next one.
|
|
jq -c --arg idx "$idx" '. + {location_idx: $idx}' <<< "$out"
|
|
return 0
|
|
}
|
|
|
|
# Same connect-and-inspect, published where the WebUI can read it.
|
|
#
|
|
# The WebUI cannot read a task's stdout, so the result is written beside the
|
|
# other generated data and polled for. A nonce echoed back from the request is
|
|
# what lets the browser tell ITS answer from a stale document left by an
|
|
# earlier attempt — without it a second read shows the first read's repository,
|
|
# which is the kind of wrong that looks entirely plausible.
|
|
restoreConnectInspectPublish()
|
|
{
|
|
local b64="${1:-}" nonce="${2:-}"
|
|
local out_dir; out_dir="$(webuiDir)/frontend/data/system"
|
|
local out_file="$out_dir/restore_read.json"
|
|
createFolders "quiet" "$sudo_user_name" "$out_dir"
|
|
|
|
local body rc
|
|
body=$(restoreConnectInspect "$b64"); rc=$?
|
|
[[ -n "$body" ]] || body='{"error":"the read produced no result"}'
|
|
|
|
local tmp; tmp=$(mktemp) || return 1
|
|
jq -c --arg nonce "$nonce" --arg at "$(date -Iseconds)" \
|
|
'. + {nonce: $nonce, checked: $at}' <<< "$body" > "$tmp" 2>/dev/null \
|
|
|| printf '{"error":"the read produced unreadable output","nonce":"%s"}\n' \
|
|
"$(_lpJsonStr "$nonce")" > "$tmp"
|
|
|
|
runFileWrite "$out_file" < "$tmp"
|
|
rm -f "$tmp"
|
|
return $rc
|
|
}
|