LibrePortal/scripts/restore/restore_system_adopt.sh
librelad 3fbc997a2d First-run restore: actually restore the system config, and check the domains
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>
2026-08-29 04:18:43 +01:00

204 lines
8.2 KiB
Bash

#!/bin/bash
# Adopt a staged system config into the live one — the step that makes a
# first-run restore actually restore anything.
#
# backupRestoreSystemConfig deliberately only STAGES: overwriting the config of
# a running control plane is not something to do automatically, and that
# caution is right. But it left first-run restore claiming a success it had not
# delivered — the installer printed "Settings restored" while the backup
# locations, domains and logins sat in a staging directory the user would have
# had to find and copy by hand. Nothing in the tree ever adopted them.
#
# So adoption is its own step, allowed only where the caution does not apply:
# a machine with nothing on it yet.
#
# WHAT IS NOT ADOPTED matters as much as what is. A backup describes a machine
# that no longer exists, and some of what it says is about that machine rather
# than about the user:
#
# general_docker_install the container user and its generated password,
# made by THIS install; taking the old one leaves
# the config naming an account that does not exist
# network_ports port allocations, re-rolled per install
# network_docker, interface and rootless wiring, decided by this
# network_rootless machine's hardware and kernel
# storage/locations the OLD machine's drives. App placement is already
# reconciled per app from the snapshot manifests
# (§3 of first-run-restore.md); adopting a registry
# of drives this box does not have would make every
# one of those lookups resolve to a phantom.
#
# Everything else is the user's: their backup repositories and the credentials
# to reach them, their domains, their logins, their settings.
# The files worth carrying across, relative to the config root.
_restoreAdoptAllowList()
{
cat <<'LIST'
backup/backup_engine
backup/backup_general
backup/backup_retention
network/network_domains
network/network_dns
network/network_whitelist
network/network_firewall
security/security_logins
security/security_ssh
webui/webui_logins
webui/webui_updater
general/general_basic
general/general_core
general/general_libreportal
general/general_mail
general/general_notifications
general/general_terminal
general/general_catalogs
LIST
}
# Subtrees whose members are indexed, so an allow-list of filenames cannot name
# them. backup/locations is the one the whole feature turns on: it holds every
# repository and its credentials, and "one password you remember unlocks the
# rest" is exactly what a first-run restore promises.
#
# storage/locations is deliberately NOT here — see the header.
_restoreAdoptAllowDirs()
{
cat <<'LIST'
backup/locations
LIST
}
# Is this machine still empty enough for adoption to be safe?
#
# Adoption overwrites live config, so this guard is the only thing standing
# between "restore onto a blank box" and "overwrite a working install". It
# therefore FAILS CLOSED: anything it cannot establish counts as not-first-run.
#
# The first version did the opposite and was wrong in exactly the way this
# project keeps being wrong. It globbed the containers directory directly — but
# the manager can traverse that directory without being able to list it, so the
# glob came back as the literal '*', the loop body skipped it as "not a
# directory", and the function fell out of the bottom returning "yes, first
# run" on a machine with three apps on it. A check whose failure mode is to not
# run reads exactly like a check that passed.
restoreAdoptIsFirstRun()
{
local base="${containers_dir%/}"
[[ -n "$base" ]] || return 1
# A completed setup means someone has already used this install.
local lock="$base/libreportal/frontend/data/.setup_complete"
runFileOp test -f "$lock" 2>/dev/null && return 1
# Listed through the container user, which owns the tree — the manager
# cannot read it directly. `find -print` failing is indistinguishable from
# an empty directory, so ask for the directory itself as a sentinel: if
# that does not come back, the listing did not work and we must not
# conclude the machine is empty.
local listing
listing=$(runFileOp find "$base" -mindepth 0 -maxdepth 1 -type d 2>/dev/null)
if ! grep -qxF "$base" <<< "$listing"; then
isNotice "Could not list $base to check whether this machine is already in use."
return 1
fi
local d name
while IFS= read -r d; do
[[ -z "$d" || "$d" == "$base" ]] && continue
name="$(basename "$d")"
[[ "$name" == "libreportal" ]] && continue
return 1
done <<< "$listing"
return 0
}
# Adopt the staged tree. Prints what it took and what it deliberately left.
#
# restoreSystemAdopt <staging-dir> [--force]
#
# --force exists for a deliberate "overwrite this install with that backup" and
# says so loudly; without it, a populated machine is refused rather than
# silently half-merged.
restoreSystemAdopt()
{
local staging="${1:-}" force="${2:-}"
if [[ -z "$staging" ]]; then
staging="${restore_dir%/}/system-config"
fi
if [[ ! -d "$staging" ]]; then
isError "No staged system config at '$staging' — run 'libreportal restore system <location>' first."
return 1
fi
if ! restoreAdoptIsFirstRun && [[ "$force" != "--force" ]]; then
isError "This machine already has apps or a completed setup — refusing to overwrite its config."
isNotice "Adopting a backup's config over a running install is not reversible from here."
isNotice "If that is really what you want: libreportal restore adopt \"$staging\" --force"
return 1
fi
# The staged tree may nest the config root one level down, depending on how
# the snapshot recorded its absolute paths. Find the level that actually
# holds the config directories rather than assuming either shape.
local root=""
local probe
for probe in "$staging" "$staging/configs"; do
if runFileOp test -d "$probe/backup" 2>/dev/null || runFileOp test -d "$probe/general" 2>/dev/null; then
root="$probe"; break
fi
done
if [[ -z "$root" ]]; then
# Fall back to a search: restic restores under the source's absolute
# path, so the config root can be several levels down.
root=$(runFileOp find "$staging" -maxdepth 6 -type d -name configs -print -quit 2>/dev/null)
fi
if [[ -z "$root" ]]; then
isError "Could not find a config tree inside '$staging'."
return 1
fi
isHeader "Adopting settings from the backup"
local rel took=0 missed=0
local -a taken=() absent=()
while IFS= read -r rel; do
[[ -z "$rel" ]] && continue
if runOwnership config-adopt "$root" "$rel" 2>/dev/null; then
taken+=("$rel"); took=$((took + 1))
else
absent+=("$rel"); missed=$((missed + 1))
fi
done < <(_restoreAdoptAllowList)
local reldir
while IFS= read -r reldir; do
[[ -z "$reldir" ]] && continue
if runOwnership config-adopt-tree "$root" "$reldir" 2>/dev/null; then
took=$((took + 1))
else
# Not fatal on its own, but this is the one people came for, so it
# is said out loud rather than counted quietly among the misses.
isNotice "No '$reldir' in this backup — backup repositories were not restored."
fi
done < <(_restoreAdoptAllowDirs)
if (( took == 0 )); then
isError "Nothing could be adopted from '$root' — the staged tree may be empty or unreadable."
return 1
fi
isSuccessful "Adopted $took config file(s), including your backup repositories and their credentials."
if (( missed > 0 )); then
# Not a failure: an older backup legitimately predates some of these.
isNotice "$missed not present in this backup (older backups do not carry every file)."
fi
isNotice "Left alone on purpose: this machine's docker user, port allocations and storage registry — those describe this box, not the backup."
# The config cache is stale the moment these land, and every later step
# (domain checks, location listing) reads through it.
storageCacheReset 2>/dev/null || true
return 0
}