The installer's restore path printed "Settings restored" and had never restored
a setting. backupRestoreSystemConfig only STAGES — right in general, since
overwriting a running control plane's config should not be automatic — but
nothing ever adopted the staged tree. The backup locations, domains and logins
landed in $restore_dir/system-config and stayed there.
So adoption is its own step now (`restore adopt`), allowed only on a machine
with nothing on it yet. backup/locations/ is adopted as a subtree, since the
index is part of the path and that directory is the whole point: it holds every
repository and its credentials, which is what makes "one password unlocks the
rest" true. Deliberately NOT adopted: the container account and its generated
password, port allocations, docker/rootless wiring, and storage/locations —
those describe the old machine, and a registry of drives this box does not have
would make every placement lookup resolve to a phantom.
The guard failed in the shape this project keeps hitting. It globbed the
containers directory, but the manager can traverse that without listing it, so
the glob returned a literal '*', the loop skipped it, and the function returned
"first run" on a machine with three apps. It adopted over a live install in
testing. It now asks the container user for the listing and fails closed: an
unreadable directory means "in use", never "empty".
Two config modes were inverted, found because a restore cannot restore from a
snapshot that was never taken:
- storage location configs were 0640 and hold no secrets. The backup runs as
the container user, could not read them, and restic wrote an INCOMPLETE
snapshot and exited 3 — so EVERY system-config backup failed once a second
storage location existed. Now 0644, with the test asserting they stay
secret-free so that mode remains defensible.
- backup location configs were 0644 and hold the repository password; nobody
could read them. They cannot simply be tightened, because the backup has to
read the credentials it uses — so the directory carries the restriction
(config-secure, manager:container 0750) and the file stays readable to the
two accounts that belong.
Fixing that surfaced a third: config-adopt clamped existing parent directories
to manager:manager 0750, closing configs/backup to the container user and
breaking the credential read the directory fix had just preserved.
restore domains reports which restored domains point here, and the installer
offers to drop the strays. Three verdicts, not two: setupCheckDomainPointsHere
falls back to hostname -I, and comparing a public A record to a private 10.x
address would condemn every correctly-pointed domain on a LAN-only box, which
is the deployment this product targets. Unverifiable is never offered for
deletion.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
157 lines
7.2 KiB
Bash
Executable File
157 lines
7.2 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 "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
|