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>
This commit is contained in:
parent
3fbc997a2d
commit
5dd763713d
@ -189,6 +189,21 @@ router.post('/secret', requireAuth, async (req, res) => {
|
||||
return res.status(503).json({ error: 'Secret channel is not set up on this host' });
|
||||
}
|
||||
|
||||
// Sweep before writing. A reference is redeemed once by the applier, but a
|
||||
// flow the user abandons — closed the tab, hit a validation error, never
|
||||
// pressed Save — leaves its secret behind, and these are repository
|
||||
// passwords. webuiSecretSweep exists for this and had no callers at all,
|
||||
// so nothing ever ran it; doing it here ties the cleanup to the one event
|
||||
// that is guaranteed to happen whenever secrets are being created.
|
||||
try {
|
||||
const cutoff = Date.now() - 15 * 60 * 1000;
|
||||
for (const name of await fsp.readdir(SECRET_DIR)) {
|
||||
const f = path.join(SECRET_DIR, name);
|
||||
const st = await fsp.stat(f).catch(() => null);
|
||||
if (st && st.isFile() && st.mtimeMs < cutoff) await fsp.unlink(f).catch(() => {});
|
||||
}
|
||||
} catch { /* a sweep that fails must never block storing the new value */ }
|
||||
|
||||
const id = require('crypto').randomBytes(16).toString('hex');
|
||||
const file = path.join(SECRET_DIR, id);
|
||||
// 0640 explicitly rather than relying on the process umask: owner writes,
|
||||
|
||||
@ -410,6 +410,61 @@ an essay attached and no lookup could match. And `updateConfigOption` writes an
|
||||
empty value as a literal `""`, so nine cleared slots read back as nine
|
||||
two-character domains and were reported as nine failures.
|
||||
|
||||
### 3.10 — Reading a repository without restoring it
|
||||
|
||||
`restore inspect <idx> [host]` answers "what would a restore from here bring?"
|
||||
without writing anything: which machines' backups are in the repository, which
|
||||
apps and how big, and — the part that is least obvious to get at — **which
|
||||
domains**. Those live in the system-config snapshot, so `engineDumpFile` pulls
|
||||
`network/network_domains` straight out of it, the same way the preflight pulls
|
||||
an app's manifest. Knowing "this backup hands you six domains, four of which
|
||||
point somewhere else" before committing is the difference between a rebuild and
|
||||
a surprise.
|
||||
|
||||
`restore connect <base64-json>` is the WebUI's entry point: it creates the
|
||||
location from a payload, redeems the repository password from the single-use
|
||||
secret channel, and inspects. It deliberately does **not** call
|
||||
`engineInitLocation` — every other path that creates a location initialises it
|
||||
because it is about to write there; this one is pointed at a repository that
|
||||
already exists and is only going to be read.
|
||||
|
||||
This is what unblocks §4. `app_portable.sh` records that a `.lpapp` can live in
|
||||
the WebUI *because* it is unencrypted and no password has to cross from the
|
||||
browser to the host — and that the repository restore therefore could not. The
|
||||
`secret:<ref>` channel is that missing piece, so the constraint no longer holds.
|
||||
|
||||
A wrong password is the ordinary case here and the user simply tries again, so
|
||||
a failed connect removes the location it just created. Without that, every
|
||||
retry left another half-configured destination behind and the Backup page grew
|
||||
a column of identical dead entries.
|
||||
|
||||
### 3.11 — Three more found by using it
|
||||
|
||||
**`locationRemove` never worked.** It removed with `runFileOp` — the container
|
||||
user — but `configs/` is manager-owned, so the unlink was always denied. The
|
||||
result was never checked, and `isSuccessful` printed regardless, so a location
|
||||
"removed" from the WebUI came straight back on the next listing. Now
|
||||
`runInstallOp`, and the directory is checked before claiming anything.
|
||||
|
||||
**`webuiSecretSweep` had no callers.** Written for exactly this and never
|
||||
wired in, so a flow the user abandoned — closed the tab, hit a validation
|
||||
error, never pressed Save — left its repository password on disk indefinitely.
|
||||
The sweep now runs in the `/api/setup/secret` route before each write, which
|
||||
ties it to the one event guaranteed to happen whenever secrets are being made.
|
||||
|
||||
**Adoption took the WebUI down.** `config-adopt` chowned every adopted file to
|
||||
`manager:manager 0640`. `webui_logins` is bind-mounted into the WebUI
|
||||
container, which then could not read its own credentials file: the container
|
||||
died with exit 137 and *no log line at all*, which is a genuinely hard failure
|
||||
to read. It also clamped every parent directory it passed through, closing
|
||||
`configs/webui` and `configs/backup` to the container user.
|
||||
|
||||
The fix is a principle rather than a special case: **a restore replaces the
|
||||
content of a config file and nothing else.** The live install already knows who
|
||||
is allowed to read each one. Adoption now preserves the destination's existing
|
||||
ownership and mode, defaults closed only for a file that did not exist, and
|
||||
never re-permissions a directory it merely passes through.
|
||||
|
||||
## 4. The password problem, stated plainly
|
||||
|
||||
**An encrypted repository cannot be opened with anything inside itself.** `CFG_BACKUP_LOC_<idx>_PASSWORD` lives in the system config — which is *inside the backup*. So on a fresh machine the user must supply the repository password by hand. There is no way around this and it is not a bug; it is what encryption means.
|
||||
|
||||
@ -20,7 +20,18 @@ locationRemove()
|
||||
local name_var="CFG_BACKUP_LOC_${idx}_NAME"
|
||||
local label="${!name_var:-Location $idx}"
|
||||
|
||||
runFileOp rm -rf "$dir"
|
||||
# runInstallOp, not runFileOp: configs/ is manager-owned and the container
|
||||
# user this used to run as cannot unlink inside it. The removal failed
|
||||
# every time while the success message below printed anyway, so a location
|
||||
# "removed" from the WebUI came straight back on the next listing.
|
||||
runInstallOp rm -rf "$dir"
|
||||
|
||||
# And check. An unconditional isSuccessful after an unchecked command is
|
||||
# what hid this: the only evidence a person ever saw was the message.
|
||||
if [[ -d "$dir" ]]; then
|
||||
isError "Location $idx '$label' could not be removed — $dir is still there."
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Best-effort unset of the now-orphaned env vars so other code in this
|
||||
# process doesn't see stale values.
|
||||
|
||||
@ -41,6 +41,18 @@ cliHandleRestoreCommands()
|
||||
# staging dir; never overwrites live config. Optional location idx.
|
||||
backupRestoreSystemConfig "$action"
|
||||
;;
|
||||
connect)
|
||||
# Connect a repository from a base64 JSON payload and report what
|
||||
# it holds. The WebUI's restore branch; see restore_inspect.sh.
|
||||
# restore connect <base64-json>
|
||||
restoreConnectInspect "$action"
|
||||
;;
|
||||
inspect)
|
||||
# Report what a restore from this repository would bring, without
|
||||
# writing anything.
|
||||
# restore inspect <location-idx> [host]
|
||||
restoreInspect "$action" "$name"
|
||||
;;
|
||||
adopt)
|
||||
# Copy a staged system config into the live one. Only on a machine
|
||||
# with nothing on it yet, unless --force. This is the step that
|
||||
|
||||
@ -125,6 +125,53 @@ else
|
||||
echo " SKIP $H not installed"
|
||||
fi
|
||||
|
||||
echo "adoption preserves what the destination already was"
|
||||
# The whole reason this is checked rather than assumed: config-adopt used to
|
||||
# chown every adopted file to manager:manager 0640. webui_logins is
|
||||
# bind-mounted into the WebUI container, which then could not read its own
|
||||
# credentials file — the container died with exit 137 and no log line at all.
|
||||
# A restore replaces the CONTENT of a config file; the live install already
|
||||
# knows who is allowed to read it.
|
||||
H=/usr/local/lib/libreportal/libreportal-ownership
|
||||
if [[ -x "$H" ]]; then
|
||||
stage=$(mktemp -d); mkdir -p "$stage/webui"; echo "adopted=yes" > "$stage/webui/webui_logins"
|
||||
target="$CONFIGS/webui/webui_logins"
|
||||
if [[ -f "$target" ]]; then
|
||||
want="$(stat -c '%a %U:%G' "$target")"
|
||||
keep=$(mktemp); cp -a "$target" "$keep"
|
||||
"$H" config-adopt "$stage" "webui/webui_logins" >/dev/null 2>&1
|
||||
got="$(stat -c '%a %U:%G' "$target")"
|
||||
chk "ownership and mode unchanged" "$got" "$want"
|
||||
grep -q '^adopted=yes$' "$target" && echo " ok the content was replaced" \
|
||||
|| { echo " FAIL the content was not replaced"; fail=1; }
|
||||
cat "$keep" > "$target"; chmod "${want%% *}" "$target"; chown "${want##* }" "$target"; rm -f "$keep"
|
||||
else
|
||||
echo " SKIP no webui_logins to test against"
|
||||
fi
|
||||
# A file that does not exist yet gets the closed default.
|
||||
mkdir -p "$stage/general"; echo "n=1" > "$stage/general/lp_test_new_file"
|
||||
"$H" config-adopt "$stage" "general/lp_test_new_file" >/dev/null 2>&1
|
||||
if [[ -f "$CONFIGS/general/lp_test_new_file" ]]; then
|
||||
chk "a new file defaults closed" "$(stat -c '%a' "$CONFIGS/general/lp_test_new_file")" "640"
|
||||
rm -f "$CONFIGS/general/lp_test_new_file"
|
||||
else
|
||||
echo " FAIL a new file was not created"; fail=1
|
||||
fi
|
||||
rm -rf "$stage"
|
||||
fi
|
||||
|
||||
echo "adoption does not re-permission directories it passes through"
|
||||
# config-adopt clamped every parent to manager:manager 0750, including ones
|
||||
# that already existed. That closed configs/backup to the container user and
|
||||
# broke the credential read, and closed configs/webui, which is what actually
|
||||
# took the WebUI down.
|
||||
for d in general network security webui backup; do
|
||||
[[ -d "$CONFIGS/$d" ]] || continue
|
||||
m=$(stat -c '%a' "$CONFIGS/$d")
|
||||
if [[ "$m" == "755" ]]; then echo " ok configs/$d is traversable ($m)"
|
||||
else echo " FAIL configs/$d is $m — the container user cannot traverse it"; fail=1; fi
|
||||
done
|
||||
|
||||
echo "the domain reader"
|
||||
# Config values carry a trailing comment column, and updateConfigOption writes
|
||||
# an empty value as a literal "". Both had to be stripped: without the first
|
||||
|
||||
255
scripts/restore/restore_inspect.sh
Normal file
255
scripts/restore/restore_inspect.sh
Normal file
@ -0,0 +1,255 @@
|
||||
#!/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
|
||||
}
|
||||
@ -11,5 +11,6 @@ restore_scripts=(
|
||||
"restore/restore_preflight.sh"
|
||||
"restore/restore_system_adopt.sh"
|
||||
"restore/restore_domains.sh"
|
||||
"restore/restore_inspect.sh"
|
||||
|
||||
)
|
||||
|
||||
@ -905,10 +905,12 @@ declare -gA LP_FN_MAP=(
|
||||
[resticSnapshotPaths]="backup/engine/restic_snapshots.sh"
|
||||
[resticSnapshotsJson]="backup/engine/restic_snapshots.sh"
|
||||
[resticSystemSnapshotsJson]="backup/engine/restic_snapshots.sh"
|
||||
[_restoreAdoptAllowDirs]="restore/restore_system_adopt.sh"
|
||||
[_restoreAdoptAllowList]="restore/restore_system_adopt.sh"
|
||||
[restoreAdoptIsFirstRun]="restore/restore_system_adopt.sh"
|
||||
[restoreAppRunHook]="restore/restore_app_hooks.sh"
|
||||
[restoreAppStart]="restore/restore_app_start.sh"
|
||||
[restoreConnectInspect]="restore/restore_inspect.sh"
|
||||
[restoreDbRehydratePreStart]="backup/db/backup_db.sh"
|
||||
[restoreDbReplayPostStart]="backup/db/backup_db.sh"
|
||||
[restoreDomainCheck]="restore/restore_domains.sh"
|
||||
@ -918,11 +920,15 @@ declare -gA LP_FN_MAP=(
|
||||
[restoreFilesRehydratePreStart]="backup/files/backup_files.sh"
|
||||
[restoreFirstRunBulk]="restore/restore_first_run.sh"
|
||||
[restoreFirstRunDiscover]="restore/restore_first_run.sh"
|
||||
[restoreInspect]="restore/restore_inspect.sh"
|
||||
[restoreInspectDomains]="restore/restore_inspect.sh"
|
||||
[_restoreIsPrivateIp]="restore/restore_domains.sh"
|
||||
[_restorePfSize]="restore/restore_preflight.sh"
|
||||
[restorePickSnapshot]="restore/restore_app_pick.sh"
|
||||
[restorePreflightApp]="restore/restore_preflight.sh"
|
||||
[restorePreflightManifest]="restore/restore_preflight.sh"
|
||||
[restorePreflightReport]="restore/restore_preflight.sh"
|
||||
[restoreServerPublicIp]="restore/restore_domains.sh"
|
||||
[restoreSystemAdopt]="restore/restore_system_adopt.sh"
|
||||
[_rocketchatApi]="rocketchat/scripts/rocketchat_auth.sh"
|
||||
[_rocketchatBaseUrl]="rocketchat/scripts/rocketchat_auth.sh"
|
||||
@ -2163,10 +2169,12 @@ declare -gA LP_FN_ROOT=(
|
||||
[resticSnapshotPaths]="scripts"
|
||||
[resticSnapshotsJson]="scripts"
|
||||
[resticSystemSnapshotsJson]="scripts"
|
||||
[_restoreAdoptAllowDirs]="scripts"
|
||||
[_restoreAdoptAllowList]="scripts"
|
||||
[restoreAdoptIsFirstRun]="scripts"
|
||||
[restoreAppRunHook]="scripts"
|
||||
[restoreAppStart]="scripts"
|
||||
[restoreConnectInspect]="scripts"
|
||||
[restoreDbRehydratePreStart]="scripts"
|
||||
[restoreDbReplayPostStart]="scripts"
|
||||
[restoreDomainCheck]="scripts"
|
||||
@ -2176,11 +2184,15 @@ declare -gA LP_FN_ROOT=(
|
||||
[restoreFilesRehydratePreStart]="scripts"
|
||||
[restoreFirstRunBulk]="scripts"
|
||||
[restoreFirstRunDiscover]="scripts"
|
||||
[restoreInspect]="scripts"
|
||||
[restoreInspectDomains]="scripts"
|
||||
[_restoreIsPrivateIp]="scripts"
|
||||
[_restorePfSize]="scripts"
|
||||
[restorePickSnapshot]="scripts"
|
||||
[restorePreflightApp]="scripts"
|
||||
[restorePreflightManifest]="scripts"
|
||||
[restorePreflightReport]="scripts"
|
||||
[restoreServerPublicIp]="scripts"
|
||||
[restoreSystemAdopt]="scripts"
|
||||
[_rocketchatApi]="containers"
|
||||
[_rocketchatBaseUrl]="containers"
|
||||
@ -3459,10 +3471,12 @@ resticSnapshotListFiles() { unset -f resticSnapshotListFiles; __lpAutoload "${in
|
||||
resticSnapshotPaths() { unset -f resticSnapshotPaths; __lpAutoload "${install_scripts_dir}backup/engine/restic_snapshots.sh"; resticSnapshotPaths "$@"; }
|
||||
resticSnapshotsJson() { unset -f resticSnapshotsJson; __lpAutoload "${install_scripts_dir}backup/engine/restic_snapshots.sh"; resticSnapshotsJson "$@"; }
|
||||
resticSystemSnapshotsJson() { unset -f resticSystemSnapshotsJson; __lpAutoload "${install_scripts_dir}backup/engine/restic_snapshots.sh"; resticSystemSnapshotsJson "$@"; }
|
||||
_restoreAdoptAllowDirs() { unset -f _restoreAdoptAllowDirs; __lpAutoload "${install_scripts_dir}restore/restore_system_adopt.sh"; _restoreAdoptAllowDirs "$@"; }
|
||||
_restoreAdoptAllowList() { unset -f _restoreAdoptAllowList; __lpAutoload "${install_scripts_dir}restore/restore_system_adopt.sh"; _restoreAdoptAllowList "$@"; }
|
||||
restoreAdoptIsFirstRun() { unset -f restoreAdoptIsFirstRun; __lpAutoload "${install_scripts_dir}restore/restore_system_adopt.sh"; restoreAdoptIsFirstRun "$@"; }
|
||||
restoreAppRunHook() { unset -f restoreAppRunHook; __lpAutoload "${install_scripts_dir}restore/restore_app_hooks.sh"; restoreAppRunHook "$@"; }
|
||||
restoreAppStart() { unset -f restoreAppStart; __lpAutoload "${install_scripts_dir}restore/restore_app_start.sh"; restoreAppStart "$@"; }
|
||||
restoreConnectInspect() { unset -f restoreConnectInspect; __lpAutoload "${install_scripts_dir}restore/restore_inspect.sh"; restoreConnectInspect "$@"; }
|
||||
restoreDbRehydratePreStart() { unset -f restoreDbRehydratePreStart; __lpAutoload "${install_scripts_dir}backup/db/backup_db.sh"; restoreDbRehydratePreStart "$@"; }
|
||||
restoreDbReplayPostStart() { unset -f restoreDbReplayPostStart; __lpAutoload "${install_scripts_dir}backup/db/backup_db.sh"; restoreDbReplayPostStart "$@"; }
|
||||
restoreDomainCheck() { unset -f restoreDomainCheck; __lpAutoload "${install_scripts_dir}restore/restore_domains.sh"; restoreDomainCheck "$@"; }
|
||||
@ -3472,11 +3486,15 @@ restoreDomainsDropElsewhere() { unset -f restoreDomainsDropElsewhere; __lpAutolo
|
||||
restoreFilesRehydratePreStart() { unset -f restoreFilesRehydratePreStart; __lpAutoload "${install_scripts_dir}backup/files/backup_files.sh"; restoreFilesRehydratePreStart "$@"; }
|
||||
restoreFirstRunBulk() { unset -f restoreFirstRunBulk; __lpAutoload "${install_scripts_dir}restore/restore_first_run.sh"; restoreFirstRunBulk "$@"; }
|
||||
restoreFirstRunDiscover() { unset -f restoreFirstRunDiscover; __lpAutoload "${install_scripts_dir}restore/restore_first_run.sh"; restoreFirstRunDiscover "$@"; }
|
||||
restoreInspect() { unset -f restoreInspect; __lpAutoload "${install_scripts_dir}restore/restore_inspect.sh"; restoreInspect "$@"; }
|
||||
restoreInspectDomains() { unset -f restoreInspectDomains; __lpAutoload "${install_scripts_dir}restore/restore_inspect.sh"; restoreInspectDomains "$@"; }
|
||||
_restoreIsPrivateIp() { unset -f _restoreIsPrivateIp; __lpAutoload "${install_scripts_dir}restore/restore_domains.sh"; _restoreIsPrivateIp "$@"; }
|
||||
_restorePfSize() { unset -f _restorePfSize; __lpAutoload "${install_scripts_dir}restore/restore_preflight.sh"; _restorePfSize "$@"; }
|
||||
restorePickSnapshot() { unset -f restorePickSnapshot; __lpAutoload "${install_scripts_dir}restore/restore_app_pick.sh"; restorePickSnapshot "$@"; }
|
||||
restorePreflightApp() { unset -f restorePreflightApp; __lpAutoload "${install_scripts_dir}restore/restore_preflight.sh"; restorePreflightApp "$@"; }
|
||||
restorePreflightManifest() { unset -f restorePreflightManifest; __lpAutoload "${install_scripts_dir}restore/restore_preflight.sh"; restorePreflightManifest "$@"; }
|
||||
restorePreflightReport() { unset -f restorePreflightReport; __lpAutoload "${install_scripts_dir}restore/restore_preflight.sh"; restorePreflightReport "$@"; }
|
||||
restoreServerPublicIp() { unset -f restoreServerPublicIp; __lpAutoload "${install_scripts_dir}restore/restore_domains.sh"; restoreServerPublicIp "$@"; }
|
||||
restoreSystemAdopt() { unset -f restoreSystemAdopt; __lpAutoload "${install_scripts_dir}restore/restore_system_adopt.sh"; restoreSystemAdopt "$@"; }
|
||||
_rocketchatApi() { unset -f _rocketchatApi; __lpAutoload "${install_containers_dir}rocketchat/scripts/rocketchat_auth.sh"; _rocketchatApi "$@"; }
|
||||
_rocketchatBaseUrl() { unset -f _rocketchatBaseUrl; __lpAutoload "${install_containers_dir}rocketchat/scripts/rocketchat_auth.sh"; _rocketchatBaseUrl "$@"; }
|
||||
|
||||
@ -561,12 +561,31 @@ config_adopt() {
|
||||
chmod 0750 -- "${real_dst%/*}" 2>/dev/null
|
||||
fi
|
||||
|
||||
# Preserve whatever the destination already is. A restore replaces the
|
||||
# CONTENT of a config file; it has no business re-deciding who may read it.
|
||||
# The live install already knows — webui_logins and webui_logs are
|
||||
# bind-mounted into the container and must stay readable by the container
|
||||
# user, and imposing manager:manager 0640 on them took the WebUI down
|
||||
# immediately (exit 137, no log line, because the process could not read
|
||||
# its own credentials file).
|
||||
local had_owner="" had_mode=""
|
||||
if [[ -e "$real_dst" ]]; then
|
||||
had_owner="$(stat -c '%U:%G' -- "$real_dst" 2>/dev/null)"
|
||||
had_mode="$(stat -c '%a' -- "$real_dst" 2>/dev/null)"
|
||||
fi
|
||||
|
||||
# --dereference: copy what a symlink points at, never the link itself.
|
||||
cp -f --dereference -- "$src" "$real_dst" || return 1
|
||||
chown "$MANAGER:$MANAGER" -- "$real_dst" || return 1
|
||||
# These files carry backup-repository passwords and login hashes, so they
|
||||
# are never group- or world-readable.
|
||||
chmod 0640 -- "$real_dst" || return 1
|
||||
|
||||
if [[ -n "$had_owner" && -n "$had_mode" ]]; then
|
||||
chown "$had_owner" -- "$real_dst" || return 1
|
||||
chmod "$had_mode" -- "$real_dst" || return 1
|
||||
else
|
||||
# New file: these can carry repository passwords and login hashes, so
|
||||
# the default is closed.
|
||||
chown "$MANAGER:$MANAGER" -- "$real_dst" || return 1
|
||||
chmod 0640 -- "$real_dst" || return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user