LibrePortal/scripts/dev/lp-restore-adopt-test
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

204 lines
9.6 KiB
Bash
Executable File

#!/bin/bash
# The first-run restore adoption path, and the config permissions it depends on.
#
# sudo scripts/dev/lp-restore-adopt-test
#
# Three things this guards, all of which shipped broken.
#
# ADOPTION EXISTS AT ALL. `restore system` only stages — it will not overwrite
# the config of a running control plane, which is right in general and wrong on
# a machine that is minutes old and being rebuilt. Nothing adopted the staged
# tree, so the installer printed "Settings restored" while the backup
# locations, domains and logins sat in a directory nobody ever copied out of.
#
# THE GUARD FAILS CLOSED. Adoption overwrites live config, so the first-run
# check is the only thing between "restore onto a blank box" and "overwrite a
# working install". The first version globbed the containers directory
# directly; the manager can traverse it without being able to list it, so the
# glob returned a literal '*', the loop skipped it, and the function returned
# "yes, first run" on a machine with three apps. It adopted over a live install
# in testing. A check whose failure mode is to not run reads as a check that
# passed.
#
# THE CONFIG MODES. Storage location configs were 0640 and hold no secrets;
# the backup runs as the container user and could not read them, so restic
# wrote an INCOMPLETE snapshot and exited non-zero — every system-config backup
# failed the moment a second storage location existed, and a first-run restore
# has nothing to restore without one. Backup location configs were 0644 and
# hold the repository password; `nobody` could read them.
REPO="$(cd "$(dirname "$0")/../.." && pwd)"
fail=0
chk(){ if [[ "$2" == "$3" ]]; then echo " ok $1"; else echo " FAIL $1: got '$2' want '$3'"; fail=1; fi; }
[[ $EUID -eq 0 ]] || { echo " SKIP needs root (reads root-owned config trees)"; exit 0; }
SYS=/libreportal-system
CONFIGS="$SYS/configs"
[[ -d "$CONFIGS" ]] || { echo " SKIP no install at $SYS"; exit 0; }
echo "config permissions"
# Backup location configs carry CFG_BACKUP_LOC_<n>_PASSWORD.
BL="$CONFIGS/backup/locations"
if [[ -d "$BL" ]]; then
perm=$(stat -c '%a' "$BL")
owner=$(stat -c '%U:%G' "$BL")
chk "backup locations dir is 0750" "$perm" "750"
chk "owned manager:container" "${owner%%:*}" "libreportal"
# The real question is not the mode but who can actually read a password.
cfg=$(find "$BL" -mindepth 2 -maxdepth 2 -name location.config -type f | head -1)
if [[ -n "$cfg" ]]; then
if grep -q "^CFG_BACKUP_LOC_[0-9]*_PASSWORD=." "$cfg" 2>/dev/null; then
sudo -u nobody test -r "$cfg" 2>/dev/null \
&& { echo " FAIL a password config is readable by 'nobody': $cfg"; fail=1; } \
|| echo " ok password config unreadable by 'nobody'"
# The two accounts that need it must still get in, or backups break.
for u in libreportal "$(stat -c '%G' "$BL")"; do
sudo -u "$u" head -c 1 "$cfg" >/dev/null 2>&1 \
&& echo " ok readable by $u" \
|| { echo " FAIL $u cannot read $cfg — backups need this"; fail=1; }
done
else
echo " SKIP no password set in $cfg"
fi
fi
fi
# Storage location configs hold a name, a path and notes. The backup must read
# them, or it writes an incomplete snapshot and calls the whole run a failure.
SL="$CONFIGS/storage/locations"
if [[ -d "$SL" ]]; then
n=0; bad=0; secret=0
while IFS= read -r c; do
n=$((n+1))
[[ "$(stat -c '%a' "$c")" == "644" ]] || bad=$((bad+1))
grep -qE "^CFG_STORAGE_LOC_[0-9]+_(PASSWORD|KEY|TOKEN|SECRET)=" "$c" && secret=$((secret+1))
done < <(find "$SL" -mindepth 2 -maxdepth 2 -name location.config -type f)
if (( n == 0 )); then
echo " SKIP no storage locations registered"
else
chk "storage configs are 0644 ($n)" "$bad" "0"
# 0644 is only defensible while they hold nothing secret. If that ever
# changes, this test should fail rather than the mode quietly staying open.
chk "and hold no secrets" "$secret" "0"
c=$(find "$SL" -mindepth 2 -maxdepth 2 -name location.config -type f | head -1)
cu=$(stat -c '%G' "$BL" 2>/dev/null || echo dockerinstall)
sudo -u "$cu" head -c 1 "$c" >/dev/null 2>&1 \
&& echo " ok readable by the backup account ($cu)" \
|| { echo " FAIL $cu cannot read $c — every system backup will be incomplete"; fail=1; }
fi
fi
echo "the adoption allow-list"
AL="$REPO/scripts/restore/restore_system_adopt.sh"
list=$(sed -n '/^backup\/backup_engine$/,/^general\/general_catalogs$/p' "$AL")
# What must never be carried across from a machine that no longer exists.
for forbidden in general/general_docker_install network/network_ports network/network_docker network/network_rootless storage/locations; do
grep -qxF "$forbidden" <<< "$list" \
&& { echo " FAIL $forbidden is in the allow-list — it describes the OLD machine"; fail=1; } \
|| echo " ok $forbidden stays local"
done
# And what the whole feature exists to carry.
for wanted in backup/backup_engine network/network_domains webui/webui_logins; do
grep -qxF "$wanted" <<< "$list" \
&& echo " ok $wanted is adopted" \
|| { echo " FAIL $wanted missing from the allow-list"; fail=1; }
done
echo "the root helper refuses unsafe paths"
H=/usr/local/lib/libreportal/libreportal-ownership
if [[ -x "$H" ]]; then
stage=$(mktemp -d); mkdir -p "$stage/general"; echo "x=1" > "$stage/general/general_basic"
# A relative path that climbs out of the config tree would be a root-owned
# write anywhere on the box.
"$H" config-adopt "$stage" "../../../etc/lp-should-not-exist" >/dev/null 2>&1 \
&& { echo " FAIL traversal accepted"; fail=1; } \
|| echo " ok refuses ../ traversal"
[[ -e /etc/lp-should-not-exist ]] && { echo " FAIL it wrote outside the config tree"; fail=1; }
"$H" config-adopt "$stage" "/etc/passwd" >/dev/null 2>&1 \
&& { echo " FAIL absolute path accepted"; fail=1; } \
|| echo " ok refuses an absolute rel path"
"$H" config-adopt "$stage" "general/does_not_exist" >/dev/null 2>&1 \
&& { echo " FAIL accepted a file not in the backup"; fail=1; } \
|| echo " ok refuses a file the backup does not have"
rm -rf "$stage"
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
# every domain arrived with an essay attached and no lookup could match, and
# without the second nine cleared slots read back as nine two-character
# domains and were reported as nine failures.
tmp=$(mktemp)
cat > "$tmp" <<'CFG'
CFG_DOMAIN_1=example.com # Domain 1 - with a comment
CFG_DOMAIN_2="" # Domain 2 - cleared
CFG_DOMAIN_3=
CFG_DOMAIN_4="quoted.example" # Domain 4
CFG_NOT_A_DOMAIN=ignore.me
CFG
got=$(
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 ' "$v"
done < "$tmp"
)
rm -f "$tmp"
chk "reads only real domains" "${got% }" "example.com quoted.example"
[[ $fail -eq 0 ]] && echo "restore adopt test: OK"
exit $fail