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:
librelad 2026-08-29 04:18:43 +01:00
parent 00b6926605
commit 3fbc997a2d
12 changed files with 880 additions and 6 deletions

View File

@ -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 14 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
View File

@ -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

View File

@ -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"

View 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)

View File

@ -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
View 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

View 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
}

View 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
}

View File

@ -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"
)

View File

@ -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 "$@"; }

View File

@ -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),

View File

@ -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