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>
204 lines
8.2 KiB
Bash
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
|
|
}
|