A snapshot is one app's data, or the settings tree — never a machine. A
four-snapshot repository is typically two apps plus two versions of the
settings, not four backups to pick between. So the choice belongs on Contents,
after unlocking, where each snapshot has a name and a date rather than being a
hash.
Every row with more than one snapshot gets a picker, defaulting to the newest.
A row with one shows its date as text: a dropdown holding a single entry is a
control that cannot be operated, and it makes a repository with one backup look
like it is hiding something.
The chain already supported this. restorePickSnapshot has always passed any
value that is not the string "latest" straight through as an id; nothing ever
offered the choice. What was missing:
- restoreInspect returns every snapshot per app and for the settings, not
just the newest.
- restoreFirstRunBulk reads an optional RESTORE_SNAPSHOT_CHOICE map instead
of hardcoding "latest". An associative array rather than an argument,
because the CLI wrapper pads argv to nine slots and a per-app map cannot
survive it; the map reaches the host as base64 JSON, validated at the route
against restic short ids and app names since both hit a command line.
- backupRestoreSystemConfig takes a snapshot AND a host.
That host was a real bug. It defaulted to this machine's install name, which is
right for "recover my own settings" and wrong for a rebuild — the snapshots
carry the name of the machine being rebuilt FROM. It surfaced the moment a
restore adopted a config with a different install name and the next lookup
found nothing at all.
Verified by restoring both settings snapshots and diffing: 28bedbb0 brings back
a config carrying example.com, cc5b6bcf one with no domains.
Two CSS traps on the picker: appearance stayed `auto`, so the browser painted
its own control and ignored the colours entirely while the computed styles
looked right; and a `background:` shorthand later in the rule silently reset the
background-image, wiping the arrow set three lines above it.
lp-restore-adopt-test asserted configs/* were mode 0755 and started failing on
configs/webui, which libreportal-ownership sets to 0751:container on purpose —
tighter, and perfectly traversable. It asserts "the container user can traverse
it" now. A test that pins an incidental number reports a regression every time
someone improves the thing it is watching.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
233 lines
11 KiB
Bash
Executable File
233 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 took the
|
|
# WebUI down.
|
|
#
|
|
# Asserted as "can the container user get in", not as a mode. The first version
|
|
# demanded 0755 and failed on configs/webui, which libreportal-ownership sets
|
|
# to 0751:container ON PURPOSE — tighter, and perfectly traversable. A test
|
|
# that pins an incidental number reports a regression every time someone makes
|
|
# the permissions better.
|
|
CU=$(stat -c '%G' "$CONFIGS/backup/locations" 2>/dev/null || echo dockerinstall)
|
|
for d in general network security webui backup; do
|
|
[[ -d "$CONFIGS/$d" ]] || continue
|
|
if sudo -u "$CU" test -x "$CONFIGS/$d" 2>/dev/null; then
|
|
echo " ok $CU can traverse configs/$d ($(stat -c '%a' "$CONFIGS/$d"))"
|
|
else
|
|
echo " FAIL $CU cannot traverse configs/$d ($(stat -c '%a %U:%G' "$CONFIGS/$d"))"; 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
|