LibrePortal/scripts/restore/restore_inspect.sh
librelad 5dd763713d Read a backup repository from the WebUI, and stop adoption breaking things
restore inspect answers "what would a restore from here bring?" without writing
anything: hosts, apps with sizes, and the domains — read out of the
system-config snapshot with engineDumpFile, the same way the preflight reads an
app manifest. Knowing a backup hands you six domains of which four point
elsewhere, before committing, is the difference between a rebuild and a
surprise.

restore connect is the WebUI entry point: creates the location from a base64
payload, redeems the repository password from the single-use secret channel,
inspects. Deliberately does not engineInitLocation — every other path that
creates a location initialises it because it is about to write there; this one
reads a repository that already exists. This is what unblocks the constraint
app_portable.sh records: a .lpapp could live in the WebUI because nothing
secret crosses from browser to host, and the repository restore could not. The
secret:<ref> channel is that missing piece.

A wrong password is the ordinary case and the user retries, so a failed connect
removes the location it just made. Otherwise every attempt left another
half-configured destination behind.

Three things found by using it:

  - locationRemove never worked. It unlinked as the container user, but
    configs/ is manager-owned, so it was always denied — and the result was
    never checked, so isSuccessful printed anyway and a "removed" location came
    back on the next listing. Now runInstallOp, and the directory is checked.

  - webuiSecretSweep had no callers. An abandoned flow left its repository
    password on disk forever. The sweep now runs in /api/setup/secret before
    each write, tied to the one event guaranteed to happen.

  - Adoption took the WebUI down. config-adopt chowned every adopted file to
    manager:manager 0640, and webui_logins is bind-mounted into the container,
    which then could not read its own credentials: exit 137 with no log line.
    It also clamped every parent directory it passed through, closing
    configs/webui and configs/backup to the container user.

    The fix is a principle, not a special case: a restore replaces the CONTENT
    of a config file and nothing else. The live install already knows who may
    read each one. Adoption preserves the destination's ownership and mode,
    defaults closed only for a file that did not exist, and never
    re-permissions a directory it passes through.

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

256 lines
9.7 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
}