#!/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 [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 # # 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 }