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>
204 lines
9.6 KiB
Bash
Executable File
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
|