Third occurrence of the same defect, in a third directory. config-adopt gave a file the destination did not already have 0640 — "these can hold secrets, so default closed" — but the config tree is 0755 and the backup account reads all of it. One 0640 file makes restic report permission denied, write an INCOMPLETE snapshot and exit 3, so the entire system-config backup is reported failed, and a first-run restore has nothing to restore from. A new file now inherits from a sibling in the same directory, matching whatever that tree's convention is rather than having a mode picked for it. Since this has now happened three times for three unrelated reasons, the test asserts the class rather than the instances: no file anywhere under configs/ may be unreadable by the backup account. Mutation-checked — chmod 0640 on any one config makes it fail. Also repaired the files the earlier buggy adoption runs clamped on this install, and fixed lp-storage-step-test's hardcoded step index, which the new Start step had shifted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
224 lines
11 KiB
Bash
Executable File
224 lines
11 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 this install did not have inherits from its siblings rather than
|
|
# getting a mode of its own. It was 0640, which is unreadable by the backup
|
|
# account — the same defect that made storage location configs break every
|
|
# system snapshot, reintroduced one directory over.
|
|
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
|
|
sib=$(find "$CONFIGS/general" -maxdepth 1 -type f ! -name lp_test_new_file -print -quit)
|
|
chk "a new file matches its siblings" \
|
|
"$(stat -c '%a' "$CONFIGS/general/lp_test_new_file")" "$(stat -c '%a' "$sib")"
|
|
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 "every adopted config stays readable by the backup"
|
|
# The assertion that would have caught all of this at once. A config the backup
|
|
# account cannot read makes restic report "permission denied", write an
|
|
# INCOMPLETE snapshot and exit 3 — so the whole system backup is reported as
|
|
# failed, and a first-run restore has nothing to restore from. It has happened
|
|
# three times now, in three different directories, each time for a different
|
|
# reason.
|
|
BACKUP_USER=$(stat -c '%G' "$CONFIGS/backup/locations" 2>/dev/null || echo dockerinstall)
|
|
unreadable=0
|
|
while IFS= read -r c; do
|
|
sudo -u "$BACKUP_USER" head -c 1 "$c" >/dev/null 2>&1 || {
|
|
echo " FAIL $BACKUP_USER cannot read $c"; unreadable=$((unreadable+1)); }
|
|
done < <(find "$CONFIGS" -type f -name '*' ! -path '*/.*' 2>/dev/null)
|
|
chk "no config is unreadable by $BACKUP_USER" "$unreadable" "0"
|
|
|
|
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
|