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:
librelad 2026-08-29 04:33:07 +01:00
parent 3fbc997a2d
commit 5dd763713d
9 changed files with 438 additions and 5 deletions

View File

@ -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,

View File

@ -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.

View File

@ -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.

View File

@ -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

View File

@ -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

View 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
}

View File

@ -11,5 +11,6 @@ restore_scripts=(
"restore/restore_preflight.sh"
"restore/restore_system_adopt.sh"
"restore/restore_domains.sh"
"restore/restore_inspect.sh"
)

View File

@ -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 "$@"; }

View File

@ -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
}