#!/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__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