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>
This commit is contained in:
parent
00b6926605
commit
3fbc997a2d
@ -312,6 +312,104 @@ is a last resort for services whose state cannot be re-initialised.
|
||||
Not implemented here: it changes what a restore does with credentials, which is
|
||||
a decision rather than a defect.
|
||||
|
||||
### 3.7 — The restore that restored nothing
|
||||
|
||||
Phases 1–4 were marked built, and the installer's restore path ran end to end
|
||||
and printed "Settings restored". It had never restored a setting.
|
||||
|
||||
`backupRestoreSystemConfig` only **stages**. That is deliberate and correct in
|
||||
general — overwriting the config of a running control plane is not something to
|
||||
do automatically — but nothing in the tree ever adopted the staged result. The
|
||||
backup locations, the domains, the logins all landed in
|
||||
`$restore_dir/system-config` and stayed there, and the one line of output a
|
||||
person actually reads said the opposite.
|
||||
|
||||
So adoption is now its own step (`restore adopt`), allowed only where the
|
||||
original 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 account and
|
||||
its generated password, made by *this* install), `network_ports` (re-rolled per
|
||||
install), `network_docker` / `network_rootless` (this box's hardware and
|
||||
kernel), and `storage/locations` — the old machine's drives. App placement is
|
||||
already reconciled per app from the snapshot manifests (§3); adopting a
|
||||
registry of drives this box does not have would make every one of those lookups
|
||||
resolve to a phantom.
|
||||
|
||||
`backup/locations/` **is** adopted, as a subtree rather than a filename, since
|
||||
the index is part of the path. That one is the whole point: it holds every
|
||||
repository and its credentials, and "one password you remember unlocks the
|
||||
rest" is the promise a first-run restore makes.
|
||||
|
||||
#### The guard, and the shape it failed in
|
||||
|
||||
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* that directory without being able to *list* it, so the glob came
|
||||
back as the literal `*`, the loop 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. It adopted over a live install in testing before the guard
|
||||
was rewritten to fail closed, asking the container user for the listing and
|
||||
treating an unreadable directory as "in use" rather than "empty".
|
||||
|
||||
That is the same shape as §3.1's silent no-ops, and it is worth naming again:
|
||||
**a check whose failure mode is to not run is indistinguishable from a check
|
||||
that passed.**
|
||||
|
||||
### 3.8 — Two config modes, inverted
|
||||
|
||||
Found while testing the above, because a first-run restore cannot restore from
|
||||
a system snapshot that was never successfully taken.
|
||||
|
||||
**Storage location configs were `0640`.** They hold a name, a path and free
|
||||
text — nothing secret. The backup runs as the container user, which could not
|
||||
read them, so restic reported `permission denied`, wrote an **INCOMPLETE**
|
||||
snapshot and exited 3. Every system-config backup failed the moment a second
|
||||
storage location existed. Now `0644`, and the test asserts they contain no
|
||||
secrets so that mode stays defensible.
|
||||
|
||||
**Backup location configs were `0644`.** They hold
|
||||
`CFG_BACKUP_LOC_<n>_PASSWORD` — the key to every backup the user has — and
|
||||
`nobody` could demonstrably read them. The mode cannot simply be tightened: the
|
||||
backup genuinely has to read the credentials it is about to use. So the
|
||||
*directory* carries the restriction instead (`config-secure`:
|
||||
manager:container, `0750`). Both accounts that belong still get in; nothing
|
||||
else can traverse, whatever the modes inside say.
|
||||
|
||||
A third bug came out of fixing that one. `config-adopt` created missing parent
|
||||
directories and clamped them to `manager:manager 0750` — including ones that
|
||||
already existed, which closed `configs/backup` to the container user and broke
|
||||
the very credential read the directory fix had just preserved. A copy has no
|
||||
business re-permissioning directories it merely passes through.
|
||||
|
||||
### 3.9 — Domain reconciliation
|
||||
|
||||
The adopted config carries the domains the **old** machine served, and DNS
|
||||
still points wherever it pointed. Nothing checked this, so the first sign of
|
||||
trouble was Traefik failing to get a certificate long after the installer said
|
||||
it had finished.
|
||||
|
||||
`restore domains` reports one verdict per domain, and the installer offers to
|
||||
drop the strays. It is a report, never a refusal: a domain that does not
|
||||
resolve here is a perfectly normal state five minutes into a rebuild.
|
||||
|
||||
Three verdicts, not two. `setupCheckDomainPointsHere` falls back to
|
||||
`hostname -I` when the public-IP lookup fails, which is fine for its own
|
||||
purposes and wrong here — comparing a public A record against a private
|
||||
`10.x` address makes every correctly-pointed domain look misconfigured, and
|
||||
this is explicitly a LAN/VPN-first product where that lookup failing is
|
||||
ordinary. So an unverifiable domain reports **unknown** and is never offered
|
||||
for deletion; only a domain that demonstrably resolves elsewhere is.
|
||||
|
||||
Two parsing bugs worth recording, both caught by running it rather than reading
|
||||
it. Config values carry a trailing comment column, so every domain arrived with
|
||||
an essay attached and no lookup could match. And `updateConfigOption` writes an
|
||||
empty value as a literal `""`, so nine cleared slots read back as nine
|
||||
two-character domains and were reported as nine failures.
|
||||
|
||||
## 4. The password problem, stated plainly
|
||||
|
||||
**An encrypted repository cannot be opened with anything inside itself.** `CFG_BACKUP_LOC_<idx>_PASSWORD` lives in the system config — which is *inside the backup*. So on a fresh machine the user must supply the repository password by hand. There is no way around this and it is not a bug; it is what encryption means.
|
||||
@ -407,7 +505,7 @@ change — and the docs should say so plainly so nobody uses it as their backup.
|
||||
| Phase | Deliverable |
|
||||
|---|---|
|
||||
| **1** ✅ | Backup destination step in the WebUI wizard (§5) — the *new setup* half |
|
||||
| **2** ✅ | Two installer paths: New setup / Restore from backup, through connect → discover → system config → apps |
|
||||
| **2** ✅ | Two installer paths: New setup / Restore from backup, through connect → discover → system config → apps. *The system-config half only staged until §3.7; it now adopts.* |
|
||||
| **3** ✅ | Preflight reconciliation report in the installer (§3) |
|
||||
| **4** ✅ | `app export` / `app import` (§7). The installer's `.lpapp` option is still open — see §9.5 |
|
||||
|
||||
|
||||
44
init.sh
44
init.sh
@ -134,7 +134,7 @@ command_symlink="/usr/local/bin/libreportal"
|
||||
# `update apply` runs as the manager and CANNOT rewrite root-owned files, so a bump
|
||||
# tells the updater the new release needs a root re-install (which re-bakes them).
|
||||
# Recorded at install in $lp_lib_dir/.footprint_version. See docs/contributing/development.md.
|
||||
footprint_version=11
|
||||
footprint_version=12
|
||||
footprint_marker="$lp_lib_dir/.footprint_version"
|
||||
|
||||
# Directories — three independently-relocatable roots (see scripts/source/paths.sh
|
||||
@ -547,10 +547,46 @@ initRestoreFromBackup()
|
||||
fi
|
||||
|
||||
# --- system config first ----------------------------------------------------
|
||||
#
|
||||
# Two steps, and the second one used to be missing entirely. `restore
|
||||
# system` only STAGES: it will not overwrite the config of a running
|
||||
# control plane, which is correct in general and wrong here, where the
|
||||
# machine is minutes old and adopting the backup is the entire point. So
|
||||
# this printed "Settings restored" while the backup locations, domains and
|
||||
# logins sat in a staging directory nobody ever copied out of.
|
||||
isNotice "Restoring settings and credentials…"
|
||||
"${as_manager[@]}" libreportal restore system "$idx" >/dev/null 2>&1 \
|
||||
&& isSuccessful "Settings restored" \
|
||||
|| isNotice "System config could not be restored — apps will still be attempted."
|
||||
if "${as_manager[@]}" libreportal restore system "$idx" >/dev/null 2>&1; then
|
||||
if "${as_manager[@]}" libreportal restore adopt; then
|
||||
:
|
||||
else
|
||||
isNotice "Settings were restored to staging but could not be adopted automatically."
|
||||
isNotice "They are safe — adopt them with: libreportal restore adopt"
|
||||
fi
|
||||
else
|
||||
isNotice "System config could not be restored — apps will still be attempted."
|
||||
fi
|
||||
|
||||
# --- domains -----------------------------------------------------------------
|
||||
#
|
||||
# The adopted config carries the domains the OLD machine served, and DNS
|
||||
# still points wherever it pointed. Nothing checked this before, so the
|
||||
# first sign of trouble was Traefik failing to get a certificate long after
|
||||
# the installer had said it was finished.
|
||||
echo ""
|
||||
# Captured, not run twice: each domain costs a DNS lookup, and the answer
|
||||
# to "is there anything to decide" is in the output we already printed.
|
||||
local dom_out
|
||||
dom_out=$("${as_manager[@]}" libreportal restore domains 2>&1)
|
||||
printf '%s\n' "$dom_out"
|
||||
if grep -q "do not point here yet" <<< "$dom_out"; then
|
||||
echo ""
|
||||
isQuestion "Keep the domains that do not point here? [Y/n]:"
|
||||
local dk; read -r dk; echo ""
|
||||
case "$dk" in
|
||||
[nN]*) "${as_manager[@]}" libreportal restore domains --drop ;;
|
||||
*) isNotice "Kept. Update their DNS A records to this server when you are ready." ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# --- apps -------------------------------------------------------------------
|
||||
local apps
|
||||
|
||||
@ -65,6 +65,13 @@ locationAdd()
|
||||
# success message printed anyway.
|
||||
} | runInstallWrite "$cfg_file" >/dev/null
|
||||
runInstallOp chmod 0644 "$cfg_file"
|
||||
# This file holds CFG_BACKUP_LOC_<idx>_PASSWORD — the key to every backup
|
||||
# the user has — and it was readable by any local account; `nobody`
|
||||
# demonstrably could. It cannot simply be 0640: the backup runs as the
|
||||
# container user and has to read the credentials it is about to use. So the
|
||||
# DIRECTORY carries the restriction (manager:container 0750) and the mode
|
||||
# here stays readable to the two accounts that belong.
|
||||
runOwnership config-secure >/dev/null 2>&1 || true
|
||||
|
||||
if declare -f replacePlainPasswords >/dev/null 2>&1; then
|
||||
replacePlainPasswords "$cfg_file"
|
||||
|
||||
@ -41,6 +41,22 @@ cliHandleRestoreCommands()
|
||||
# staging dir; never overwrites live config. Optional location idx.
|
||||
backupRestoreSystemConfig "$action"
|
||||
;;
|
||||
adopt)
|
||||
# Copy a staged system config into the live one. Only on a machine
|
||||
# with nothing on it yet, unless --force. This is the step that
|
||||
# makes a first-run restore restore anything: staging alone left
|
||||
# the installer claiming settings were back when they were not.
|
||||
# restore adopt [staging-dir] [--force]
|
||||
restoreSystemAdopt "$action" "$name"
|
||||
;;
|
||||
domains)
|
||||
# Report which restored domains actually point at this server.
|
||||
# restore domains [--drop]
|
||||
restoreDomainReport || return
|
||||
if [[ "$action" == "--drop" ]]; then
|
||||
restoreDomainsDropElsewhere
|
||||
fi
|
||||
;;
|
||||
migrate)
|
||||
case "$action" in
|
||||
app)
|
||||
|
||||
@ -11,6 +11,12 @@ cliShowRestoreHelp()
|
||||
echo ""
|
||||
echo "restore system [loc_idx]"
|
||||
echo " Restore the latest system-config snapshot into a staging dir"
|
||||
echo ""
|
||||
echo " restore adopt [dir] [--force]"
|
||||
echo " Copy a staged system config into the live one (first-run only without --force)"
|
||||
echo ""
|
||||
echo " restore domains [--drop]"
|
||||
echo " Check restored domains against this server; --drop removes the ones that do not point here"
|
||||
echo " (review-then-copy; never overwrites live config). Default: first location."
|
||||
echo ""
|
||||
echo "restore migrate discover [loc_idx]"
|
||||
|
||||
156
scripts/dev/lp-restore-adopt-test
Executable file
156
scripts/dev/lp-restore-adopt-test
Executable file
@ -0,0 +1,156 @@
|
||||
#!/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
|
||||
189
scripts/restore/restore_domains.sh
Normal file
189
scripts/restore/restore_domains.sh
Normal file
@ -0,0 +1,189 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Domain reconciliation for a restore.
|
||||
#
|
||||
# A backup carries the domains the OLD machine served. After adoption they are
|
||||
# this machine's domains — but DNS still points at a server that may not exist
|
||||
# any more. Nothing in the restore path checked this, so the first sign of
|
||||
# trouble was Traefik failing to get a certificate long after the installer had
|
||||
# said it was finished.
|
||||
#
|
||||
# This is a report, never a refusal. A domain that does not resolve here is a
|
||||
# perfectly normal state five minutes into a rebuild: the user is about to go
|
||||
# and repoint it. What is not acceptable is not being told.
|
||||
#
|
||||
# Emits one record per domain on stdout:
|
||||
#
|
||||
# <verdict>\t<domain>\t<domain_ip>\t<detail>
|
||||
#
|
||||
# verdict is ok | elsewhere | unresolved.
|
||||
|
||||
# Every CFG_DOMAIN_N actually set, in slot order.
|
||||
restoreDomainList()
|
||||
{
|
||||
local f="${configs_dir%/}/network/network_domains"
|
||||
[[ -r "$f" ]] || return 0
|
||||
local line v
|
||||
while IFS= read -r line; do
|
||||
[[ "$line" =~ ^CFG_DOMAIN_[0-9]+= ]] || continue
|
||||
v="${line#*=}"
|
||||
# The config files carry a trailing comment column; strip it and the
|
||||
# whitespace that pads it, or every domain arrives with an essay
|
||||
# attached and no DNS lookup ever matches.
|
||||
v="${v%%#*}"
|
||||
v="${v#"${v%%[![:space:]]*}"}"
|
||||
v="${v%"${v##*[![:space:]]}"}"
|
||||
# updateConfigOption writes an empty value as a literal "" — without
|
||||
# stripping the quotes every cleared slot reads back as a two-character
|
||||
# domain, and a config with nine empty slots reports nine failures.
|
||||
v="${v%\"}"; v="${v#\"}"
|
||||
v="${v%\'}"; v="${v#\'}"
|
||||
v="${v#"${v%%[![:space:]]*}"}"
|
||||
v="${v%"${v##*[![:space:]]}"}"
|
||||
[[ -n "$v" ]] && printf '%s\n' "$v"
|
||||
done < "$f"
|
||||
}
|
||||
|
||||
# This server's public address, or empty when it cannot be established.
|
||||
#
|
||||
# Kept apart from the LAN address on purpose. setupCheckDomainPointsHere falls
|
||||
# back to `hostname -I` when the public lookup fails, which is fine for its own
|
||||
# purposes but wrong here: comparing a public A record against a private
|
||||
# 10.x address makes every correctly-pointed domain look misconfigured, and
|
||||
# LibrePortal is explicitly a LAN/VPN-first product where that lookup failing
|
||||
# is ordinary rather than exceptional.
|
||||
restoreServerPublicIp()
|
||||
{
|
||||
local ip
|
||||
ip=$(dig +short +time=3 +tries=1 myip.opendns.com @resolver1.opendns.com 2>/dev/null | head -1)
|
||||
[[ "$ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] || return 1
|
||||
printf '%s' "$ip"
|
||||
}
|
||||
|
||||
_restoreIsPrivateIp()
|
||||
{
|
||||
case "${1:-}" in
|
||||
10.*|127.*|192.168.*|169.254.*) return 0 ;;
|
||||
172.1[6-9].*|172.2[0-9].*|172.3[01].*) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Check one domain against a known server address.
|
||||
#
|
||||
# The server address is passed in rather than resolved here: it is the same for
|
||||
# every domain, and looking it up per domain meant a network round trip each
|
||||
# time for an answer that could not have changed.
|
||||
restoreDomainCheck()
|
||||
{
|
||||
local domain="$1" server_ip="$2"
|
||||
local ip
|
||||
ip=$(dig +short +time=3 +tries=1 "$domain" A 2>/dev/null | grep -E '^[0-9.]+$' | head -1)
|
||||
|
||||
if [[ -z "$ip" ]]; then
|
||||
# A literal '-' rather than an empty field: tab is IFS whitespace, so
|
||||
# `IFS=$'\t' read` collapses two adjacent tabs into one and every
|
||||
# column after the gap shifts left — which silently emptied the detail.
|
||||
printf 'unresolved\t%s\t-\t%s\n' "$domain" "no DNS record found"
|
||||
elif [[ -z "$server_ip" ]]; then
|
||||
printf 'unknown\t%s\t%s\t%s\n' "$domain" "$ip" "resolves to $ip — could not check this server's public address"
|
||||
elif [[ "$ip" == "$server_ip" ]]; then
|
||||
printf 'ok\t%s\t%s\t%s\n' "$domain" "$ip" "points at this server"
|
||||
else
|
||||
printf 'elsewhere\t%s\t%s\t%s\n' "$domain" "$ip" "points at $ip, not this server"
|
||||
fi
|
||||
}
|
||||
|
||||
# Report on every restored domain. Leaves the ones that resolve here in
|
||||
# RESTORE_DOMAINS_OK and the rest in RESTORE_DOMAINS_ELSEWHERE, so a caller can
|
||||
# offer to leave the strays out of the Traefik config rather than shipping a
|
||||
# router for a name that cannot reach this box.
|
||||
restoreDomainReport()
|
||||
{
|
||||
RESTORE_DOMAINS_OK=()
|
||||
RESTORE_DOMAINS_ELSEWHERE=()
|
||||
RESTORE_DOMAINS_UNKNOWN=()
|
||||
|
||||
local -a domains=()
|
||||
local d
|
||||
while IFS= read -r d; do [[ -n "$d" ]] && domains+=("$d"); done < <(restoreDomainList)
|
||||
|
||||
if (( ${#domains[@]} == 0 )); then
|
||||
isNotice "No domains in the restored config — nothing to check."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Resolved once rather than per domain: it cannot change between them.
|
||||
local server_ip=""
|
||||
server_ip=$(restoreServerPublicIp) || server_ip=""
|
||||
|
||||
local lan_ip; lan_ip=$(hostname -I 2>/dev/null | awk '{print $1}')
|
||||
if [[ -n "$server_ip" ]]; then
|
||||
isNotice "Checking ${#domains[@]} restored domain(s) against this server ($server_ip)…"
|
||||
else
|
||||
isNotice "Checking ${#domains[@]} restored domain(s)…"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
local rec verdict domain ip detail
|
||||
for domain in "${domains[@]}"; do
|
||||
rec=$(restoreDomainCheck "$domain" "$server_ip")
|
||||
IFS=$'\t' read -r verdict domain ip detail <<< "$rec"
|
||||
case "$verdict" in
|
||||
ok)
|
||||
printf ' \033[0;32m✓\033[0m %-28s %s\n' "$domain" "$detail"
|
||||
RESTORE_DOMAINS_OK+=("$domain") ;;
|
||||
elsewhere)
|
||||
printf ' \033[0;33m!\033[0m %-28s %s\n' "$domain" "$detail"
|
||||
RESTORE_DOMAINS_ELSEWHERE+=("$domain") ;;
|
||||
unknown)
|
||||
# Unverifiable is not the same as wrong, and offering to delete
|
||||
# a domain on the strength of a failed lookup would be.
|
||||
printf ' \033[0;33m?\033[0m %-28s %s\n' "$domain" "$detail"
|
||||
RESTORE_DOMAINS_UNKNOWN+=("$domain") ;;
|
||||
*)
|
||||
printf ' \033[0;33m?\033[0m %-28s %s\n' "$domain" "$detail"
|
||||
RESTORE_DOMAINS_ELSEWHERE+=("$domain") ;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo ""
|
||||
if (( ${#RESTORE_DOMAINS_UNKNOWN[@]} > 0 )); then
|
||||
isNotice "Could not work out this server's public address, so ${#RESTORE_DOMAINS_UNKNOWN[@]} domain(s) could not be checked."
|
||||
isNotice "That is expected on a LAN-only or VPN-only box${lan_ip:+ (this one answers on $lan_ip)}."
|
||||
fi
|
||||
if (( ${#RESTORE_DOMAINS_ELSEWHERE[@]} > 0 )); then
|
||||
isNotice "${#RESTORE_DOMAINS_ELSEWHERE[@]} of ${#domains[@]} do not point here yet."
|
||||
isNotice "That is normal mid-rebuild — update the DNS A records to this server and they will work."
|
||||
isNotice "Until then Traefik cannot get a certificate for them, so those sites stay unreachable."
|
||||
else
|
||||
isSuccessful "Every restored domain already points at this server."
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Drop the domains that do not point here out of the live config, keeping their
|
||||
# slot order for the ones that stay.
|
||||
#
|
||||
# Offered rather than done: a user who is about to repoint DNS wants them kept,
|
||||
# and a user rebuilding onto a box that will never own those names wants them
|
||||
# gone. Guessing either way is worse than asking.
|
||||
restoreDomainsDropElsewhere()
|
||||
{
|
||||
local f="${configs_dir%/}/network/network_domains"
|
||||
[[ -r "$f" ]] || return 1
|
||||
(( ${#RESTORE_DOMAINS_ELSEWHERE[@]} > 0 )) || return 0
|
||||
|
||||
local d slot=1
|
||||
for d in "${RESTORE_DOMAINS_OK[@]}"; do
|
||||
updateConfigOption "CFG_DOMAIN_$slot" "$d" >/dev/null 2>&1
|
||||
slot=$((slot + 1))
|
||||
done
|
||||
while (( slot <= 9 )); do
|
||||
updateConfigOption "CFG_DOMAIN_$slot" "" >/dev/null 2>&1
|
||||
slot=$((slot + 1))
|
||||
done
|
||||
isSuccessful "Kept ${#RESTORE_DOMAINS_OK[@]} domain(s); removed ${#RESTORE_DOMAINS_ELSEWHERE[@]} that do not point here."
|
||||
isNotice "Add them back on the Domains page once DNS is updated."
|
||||
return 0
|
||||
}
|
||||
203
scripts/restore/restore_system_adopt.sh
Normal file
203
scripts/restore/restore_system_adopt.sh
Normal file
@ -0,0 +1,203 @@
|
||||
#!/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
|
||||
}
|
||||
@ -9,5 +9,7 @@ restore_scripts=(
|
||||
"restore/restore_app_start.sh"
|
||||
"restore/restore_first_run.sh"
|
||||
"restore/restore_preflight.sh"
|
||||
"restore/restore_system_adopt.sh"
|
||||
"restore/restore_domains.sh"
|
||||
|
||||
)
|
||||
|
||||
@ -905,10 +905,16 @@ declare -gA LP_FN_MAP=(
|
||||
[resticSnapshotPaths]="backup/engine/restic_snapshots.sh"
|
||||
[resticSnapshotsJson]="backup/engine/restic_snapshots.sh"
|
||||
[resticSystemSnapshotsJson]="backup/engine/restic_snapshots.sh"
|
||||
[_restoreAdoptAllowList]="restore/restore_system_adopt.sh"
|
||||
[restoreAdoptIsFirstRun]="restore/restore_system_adopt.sh"
|
||||
[restoreAppRunHook]="restore/restore_app_hooks.sh"
|
||||
[restoreAppStart]="restore/restore_app_start.sh"
|
||||
[restoreDbRehydratePreStart]="backup/db/backup_db.sh"
|
||||
[restoreDbReplayPostStart]="backup/db/backup_db.sh"
|
||||
[restoreDomainCheck]="restore/restore_domains.sh"
|
||||
[restoreDomainList]="restore/restore_domains.sh"
|
||||
[restoreDomainReport]="restore/restore_domains.sh"
|
||||
[restoreDomainsDropElsewhere]="restore/restore_domains.sh"
|
||||
[restoreFilesRehydratePreStart]="backup/files/backup_files.sh"
|
||||
[restoreFirstRunBulk]="restore/restore_first_run.sh"
|
||||
[restoreFirstRunDiscover]="restore/restore_first_run.sh"
|
||||
@ -917,6 +923,7 @@ declare -gA LP_FN_MAP=(
|
||||
[restorePreflightApp]="restore/restore_preflight.sh"
|
||||
[restorePreflightManifest]="restore/restore_preflight.sh"
|
||||
[restorePreflightReport]="restore/restore_preflight.sh"
|
||||
[restoreSystemAdopt]="restore/restore_system_adopt.sh"
|
||||
[_rocketchatApi]="rocketchat/scripts/rocketchat_auth.sh"
|
||||
[_rocketchatBaseUrl]="rocketchat/scripts/rocketchat_auth.sh"
|
||||
[_rocketchatError]="rocketchat/scripts/rocketchat_auth.sh"
|
||||
@ -2156,10 +2163,16 @@ declare -gA LP_FN_ROOT=(
|
||||
[resticSnapshotPaths]="scripts"
|
||||
[resticSnapshotsJson]="scripts"
|
||||
[resticSystemSnapshotsJson]="scripts"
|
||||
[_restoreAdoptAllowList]="scripts"
|
||||
[restoreAdoptIsFirstRun]="scripts"
|
||||
[restoreAppRunHook]="scripts"
|
||||
[restoreAppStart]="scripts"
|
||||
[restoreDbRehydratePreStart]="scripts"
|
||||
[restoreDbReplayPostStart]="scripts"
|
||||
[restoreDomainCheck]="scripts"
|
||||
[restoreDomainList]="scripts"
|
||||
[restoreDomainReport]="scripts"
|
||||
[restoreDomainsDropElsewhere]="scripts"
|
||||
[restoreFilesRehydratePreStart]="scripts"
|
||||
[restoreFirstRunBulk]="scripts"
|
||||
[restoreFirstRunDiscover]="scripts"
|
||||
@ -2168,6 +2181,7 @@ declare -gA LP_FN_ROOT=(
|
||||
[restorePreflightApp]="scripts"
|
||||
[restorePreflightManifest]="scripts"
|
||||
[restorePreflightReport]="scripts"
|
||||
[restoreSystemAdopt]="scripts"
|
||||
[_rocketchatApi]="containers"
|
||||
[_rocketchatBaseUrl]="containers"
|
||||
[_rocketchatError]="containers"
|
||||
@ -3445,10 +3459,16 @@ resticSnapshotListFiles() { unset -f resticSnapshotListFiles; __lpAutoload "${in
|
||||
resticSnapshotPaths() { unset -f resticSnapshotPaths; __lpAutoload "${install_scripts_dir}backup/engine/restic_snapshots.sh"; resticSnapshotPaths "$@"; }
|
||||
resticSnapshotsJson() { unset -f resticSnapshotsJson; __lpAutoload "${install_scripts_dir}backup/engine/restic_snapshots.sh"; resticSnapshotsJson "$@"; }
|
||||
resticSystemSnapshotsJson() { unset -f resticSystemSnapshotsJson; __lpAutoload "${install_scripts_dir}backup/engine/restic_snapshots.sh"; resticSystemSnapshotsJson "$@"; }
|
||||
_restoreAdoptAllowList() { unset -f _restoreAdoptAllowList; __lpAutoload "${install_scripts_dir}restore/restore_system_adopt.sh"; _restoreAdoptAllowList "$@"; }
|
||||
restoreAdoptIsFirstRun() { unset -f restoreAdoptIsFirstRun; __lpAutoload "${install_scripts_dir}restore/restore_system_adopt.sh"; restoreAdoptIsFirstRun "$@"; }
|
||||
restoreAppRunHook() { unset -f restoreAppRunHook; __lpAutoload "${install_scripts_dir}restore/restore_app_hooks.sh"; restoreAppRunHook "$@"; }
|
||||
restoreAppStart() { unset -f restoreAppStart; __lpAutoload "${install_scripts_dir}restore/restore_app_start.sh"; restoreAppStart "$@"; }
|
||||
restoreDbRehydratePreStart() { unset -f restoreDbRehydratePreStart; __lpAutoload "${install_scripts_dir}backup/db/backup_db.sh"; restoreDbRehydratePreStart "$@"; }
|
||||
restoreDbReplayPostStart() { unset -f restoreDbReplayPostStart; __lpAutoload "${install_scripts_dir}backup/db/backup_db.sh"; restoreDbReplayPostStart "$@"; }
|
||||
restoreDomainCheck() { unset -f restoreDomainCheck; __lpAutoload "${install_scripts_dir}restore/restore_domains.sh"; restoreDomainCheck "$@"; }
|
||||
restoreDomainList() { unset -f restoreDomainList; __lpAutoload "${install_scripts_dir}restore/restore_domains.sh"; restoreDomainList "$@"; }
|
||||
restoreDomainReport() { unset -f restoreDomainReport; __lpAutoload "${install_scripts_dir}restore/restore_domains.sh"; restoreDomainReport "$@"; }
|
||||
restoreDomainsDropElsewhere() { unset -f restoreDomainsDropElsewhere; __lpAutoload "${install_scripts_dir}restore/restore_domains.sh"; restoreDomainsDropElsewhere "$@"; }
|
||||
restoreFilesRehydratePreStart() { unset -f restoreFilesRehydratePreStart; __lpAutoload "${install_scripts_dir}backup/files/backup_files.sh"; restoreFilesRehydratePreStart "$@"; }
|
||||
restoreFirstRunBulk() { unset -f restoreFirstRunBulk; __lpAutoload "${install_scripts_dir}restore/restore_first_run.sh"; restoreFirstRunBulk "$@"; }
|
||||
restoreFirstRunDiscover() { unset -f restoreFirstRunDiscover; __lpAutoload "${install_scripts_dir}restore/restore_first_run.sh"; restoreFirstRunDiscover "$@"; }
|
||||
@ -3457,6 +3477,7 @@ restorePickSnapshot() { unset -f restorePickSnapshot; __lpAutoload "${install_sc
|
||||
restorePreflightApp() { unset -f restorePreflightApp; __lpAutoload "${install_scripts_dir}restore/restore_preflight.sh"; restorePreflightApp "$@"; }
|
||||
restorePreflightManifest() { unset -f restorePreflightManifest; __lpAutoload "${install_scripts_dir}restore/restore_preflight.sh"; restorePreflightManifest "$@"; }
|
||||
restorePreflightReport() { unset -f restorePreflightReport; __lpAutoload "${install_scripts_dir}restore/restore_preflight.sh"; restorePreflightReport "$@"; }
|
||||
restoreSystemAdopt() { unset -f restoreSystemAdopt; __lpAutoload "${install_scripts_dir}restore/restore_system_adopt.sh"; restoreSystemAdopt "$@"; }
|
||||
_rocketchatApi() { unset -f _rocketchatApi; __lpAutoload "${install_containers_dir}rocketchat/scripts/rocketchat_auth.sh"; _rocketchatApi "$@"; }
|
||||
_rocketchatBaseUrl() { unset -f _rocketchatBaseUrl; __lpAutoload "${install_containers_dir}rocketchat/scripts/rocketchat_auth.sh"; _rocketchatBaseUrl "$@"; }
|
||||
_rocketchatError() { unset -f _rocketchatError; __lpAutoload "${install_containers_dir}rocketchat/scripts/rocketchat_auth.sh"; _rocketchatError "$@"; }
|
||||
|
||||
@ -66,7 +66,13 @@ _storageWriteLocationConfig()
|
||||
echo "CFG_STORAGE_LOC_${id}_PATH=\"${path}\" # Path - Where this location lives on disk (registry-owned; shown for reference) **READONLY**"
|
||||
echo "CFG_STORAGE_LOC_${id}_NOTES=\"\" # Notes - Free text, e.g. which physical disk this is"
|
||||
} | runInstallWrite "$cfg" >/dev/null
|
||||
runInstallOp chmod 0640 "$cfg"
|
||||
# 0644, not 0640: this file holds a name, a path and free-text notes —
|
||||
# nothing secret — and the backup runs as its own user, which could not
|
||||
# read it at 0640. That made restic report "permission denied", write an
|
||||
# INCOMPLETE system snapshot and exit non-zero, so *every* system-config
|
||||
# backup failed the moment a second storage location existed — and a
|
||||
# first-run restore has nothing to restore without one.
|
||||
runInstallOp chmod 0644 "$cfg"
|
||||
}
|
||||
|
||||
# Add a storage location: fitness first (so the user gets every reason at once),
|
||||
|
||||
@ -511,6 +511,137 @@ restore_stage() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# Adopt ONE file from a restore staging tree into the live config tree.
|
||||
#
|
||||
# Root has to do this: the staging tree is owned by the container user (restic
|
||||
# wrote it) and the manager can traverse but not read inside, so the account
|
||||
# that owns the destination cannot read the source.
|
||||
#
|
||||
# Deliberately one file per call, driven by an allow-list in the caller. A
|
||||
# recursive copy of a whole configs tree would carry the SOURCE machine's
|
||||
# identity across — its docker user and generated password, its port
|
||||
# allocations, its storage registry naming drives this box does not have. The
|
||||
# caller decides what is portable; this only enforces where it may land.
|
||||
config_adopt() {
|
||||
local staging="${1:-}" rel="${2:-}"
|
||||
[[ -n "$staging" && -n "$rel" ]] || { echo "libreportal-ownership: config-adopt needs a staging dir and a relative path" >&2; return 2; }
|
||||
|
||||
# The relative path must stay relative and stay inside the config tree. A
|
||||
# rel of "../../etc/shadow" would otherwise be a root-owned write anywhere.
|
||||
case "$rel" in
|
||||
/*|*..*) echo "libreportal-ownership: refusing unsafe config path: $rel" >&2; return 1 ;;
|
||||
esac
|
||||
case "$staging" in
|
||||
/*) ;;
|
||||
*) echo "libreportal-ownership: staging must be an absolute path" >&2; return 1 ;;
|
||||
esac
|
||||
|
||||
local src="${staging%/}/$rel"
|
||||
local dst="$CONFIGS_DIR/$rel"
|
||||
[[ -f "$src" ]] || { echo "libreportal-ownership: no such file in the backup: $rel" >&2; return 1; }
|
||||
|
||||
# Resolve and re-check: a symlink inside the staging tree could otherwise
|
||||
# point the read anywhere, and the destination must land under CONFIGS_DIR
|
||||
# no matter what the path looked like before normalisation.
|
||||
local real_dst
|
||||
real_dst="$(realpath -m -- "$dst")" || return 1
|
||||
case "$real_dst" in
|
||||
"$CONFIGS_DIR"/*) ;;
|
||||
*) echo "libreportal-ownership: refusing to write outside the config tree: $real_dst" >&2; return 1 ;;
|
||||
esac
|
||||
|
||||
# Create the parent if it is missing, but never re-permission one that
|
||||
# already exists. Clamping every parent to manager:manager 0750 is what
|
||||
# closed configs/backup to the container user and stopped it reading the
|
||||
# backup credentials it runs with — a copy has no business rewriting the
|
||||
# permissions of directories it merely passes through.
|
||||
if [[ ! -d "${real_dst%/*}" ]]; then
|
||||
mkdir -p -- "${real_dst%/*}" || return 1
|
||||
chown "$MANAGER:$MANAGER" -- "${real_dst%/*}" 2>/dev/null
|
||||
chmod 0750 -- "${real_dst%/*}" 2>/dev/null
|
||||
fi
|
||||
|
||||
# --dereference: copy what a symlink points at, never the link itself.
|
||||
cp -f --dereference -- "$src" "$real_dst" || return 1
|
||||
chown "$MANAGER:$MANAGER" -- "$real_dst" || return 1
|
||||
# These files carry backup-repository passwords and login hashes, so they
|
||||
# are never group- or world-readable.
|
||||
chmod 0640 -- "$real_dst" || return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
# Close the backup-location config directory to accounts that are not part of
|
||||
# LibrePortal.
|
||||
#
|
||||
# Those files hold CFG_BACKUP_LOC_<idx>_PASSWORD — the key to every backup the
|
||||
# user has — and they were world-readable: `nobody` could read them. The file
|
||||
# mode cannot simply be tightened, because the backup runs as the container
|
||||
# user and genuinely has to read the credentials it is about to use.
|
||||
#
|
||||
# So the directory carries the restriction instead: owned by the manager, group
|
||||
# the container user, 0750. Both accounts that need it still get in; nothing
|
||||
# else can traverse, whatever the modes inside say.
|
||||
config_secure() {
|
||||
local d="$CONFIGS_DIR/backup/locations"
|
||||
[[ -d "$d" ]] || return 0
|
||||
local mode cowner; mode="$(_mode)"; cowner="$(_container_owner "$mode")"
|
||||
chown "$MANAGER:$cowner" -- "$d" || return 1
|
||||
chmod 0750 -- "$d" || return 1
|
||||
# Per-location subdirectories, same reasoning.
|
||||
local sub
|
||||
for sub in "$d"/*/; do
|
||||
[[ -d "$sub" ]] || continue
|
||||
chown "$MANAGER:$cowner" -- "$sub" 2>/dev/null
|
||||
chmod 0750 -- "$sub" 2>/dev/null
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
# Adopt a whole config SUBTREE from a restore staging tree.
|
||||
#
|
||||
# For the per-location directories, where the index is part of the path and an
|
||||
# allow-list of fixed filenames cannot name them. backup/locations/<n>/ is the
|
||||
# one that matters: it holds the repository credentials, and a restore that
|
||||
# does not bring those back has not restored the thing the user came for.
|
||||
#
|
||||
# Files land 0644, not 0640, deliberately — the backup runs as the container
|
||||
# user and has to read the credentials it is about to use. What keeps everyone
|
||||
# else out is the directory (config_secure, manager:container 0750), which this
|
||||
# re-applies once the copy is done.
|
||||
config_adopt_tree() {
|
||||
local staging="${1:-}" rel="${2:-}"
|
||||
[[ -n "$staging" && -n "$rel" ]] || { echo "libreportal-ownership: config-adopt-tree needs a staging dir and a relative path" >&2; return 2; }
|
||||
case "$rel" in
|
||||
/*|*..*) echo "libreportal-ownership: refusing unsafe config path: $rel" >&2; return 1 ;;
|
||||
esac
|
||||
case "$staging" in
|
||||
/*) ;;
|
||||
*) echo "libreportal-ownership: staging must be an absolute path" >&2; return 1 ;;
|
||||
esac
|
||||
|
||||
local src="${staging%/}/$rel"
|
||||
[[ -d "$src" ]] || { echo "libreportal-ownership: no such directory in the backup: $rel" >&2; return 1; }
|
||||
|
||||
local dst real_dst
|
||||
dst="$CONFIGS_DIR/$rel"
|
||||
real_dst="$(realpath -m -- "$dst")" || return 1
|
||||
case "$real_dst" in
|
||||
"$CONFIGS_DIR"/*) ;;
|
||||
*) echo "libreportal-ownership: refusing to write outside the config tree: $real_dst" >&2; return 1 ;;
|
||||
esac
|
||||
|
||||
mkdir -p -- "$real_dst" || return 1
|
||||
# --no-dereference is wrong here and -L is right: a symlink in the staging
|
||||
# tree must be resolved to its content, never recreated as a link that
|
||||
# could point anywhere once it lands in the config tree.
|
||||
cp -RfL --no-preserve=mode,ownership -- "$src/." "$real_dst/" || return 1
|
||||
chown -R "$MANAGER:$MANAGER" -- "$real_dst" || return 1
|
||||
find "$real_dst" -type d -exec chmod 0750 {} + 2>/dev/null
|
||||
find "$real_dst" -type f -exec chmod 0644 {} + 2>/dev/null
|
||||
config_secure
|
||||
return 0
|
||||
}
|
||||
|
||||
action="${1:-}"; shift 2>/dev/null || true
|
||||
case "$action" in
|
||||
reconcile) reconcile "${1:-}";;
|
||||
@ -530,5 +661,8 @@ case "$action" in
|
||||
restore-stage) restore_stage "${1:-}";;
|
||||
restore-unstage) restore_unstage "${1:-}";;
|
||||
secret-dir) secret_dir;;
|
||||
config-adopt) config_adopt "${1:-}" "${2:-}";;
|
||||
config-secure) config_secure;;
|
||||
config-adopt-tree) config_adopt_tree "${1:-}" "${2:-}";;
|
||||
*) echo "usage: libreportal-ownership {reconcile [mode]|traversal|containers-top|backups-top|db-own|app-perms|webui|webui-bind|taskdir|app-data-nobody <app>|app-data-remove <app>|app-file <app> <relpath>|app-move <app> <dest-root>|app-adopt <app> <staged> <dest-root>|restore-stage <path>|restore-unstage <path>|secret-dir}" >&2; exit 2;;
|
||||
esac
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user