diff --git a/docs/roadmap/first-run-restore.md b/docs/roadmap/first-run-restore.md index 1dc4da1..d85fb56 100644 --- a/docs/roadmap/first-run-restore.md +++ b/docs/roadmap/first-run-restore.md @@ -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__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__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 | diff --git a/init.sh b/init.sh index 5e1ad15..4c0dc8e 100755 --- a/init.sh +++ b/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 diff --git a/scripts/backup/locations/location_add.sh b/scripts/backup/locations/location_add.sh index eacf5a3..7d6e05a 100644 --- a/scripts/backup/locations/location_add.sh +++ b/scripts/backup/locations/location_add.sh @@ -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__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" diff --git a/scripts/cli/commands/restore/cli_restore_commands.sh b/scripts/cli/commands/restore/cli_restore_commands.sh index fc417c2..bacf4d3 100755 --- a/scripts/cli/commands/restore/cli_restore_commands.sh +++ b/scripts/cli/commands/restore/cli_restore_commands.sh @@ -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) diff --git a/scripts/cli/commands/restore/cli_restore_header.sh b/scripts/cli/commands/restore/cli_restore_header.sh index ff98666..cdbd02c 100755 --- a/scripts/cli/commands/restore/cli_restore_header.sh +++ b/scripts/cli/commands/restore/cli_restore_header.sh @@ -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]" diff --git a/scripts/dev/lp-restore-adopt-test b/scripts/dev/lp-restore-adopt-test new file mode 100755 index 0000000..f5cadba --- /dev/null +++ b/scripts/dev/lp-restore-adopt-test @@ -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__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 diff --git a/scripts/restore/restore_domains.sh b/scripts/restore/restore_domains.sh new file mode 100644 index 0000000..278bf8f --- /dev/null +++ b/scripts/restore/restore_domains.sh @@ -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: +# +# \t\t\t +# +# 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 +} diff --git a/scripts/restore/restore_system_adopt.sh b/scripts/restore/restore_system_adopt.sh new file mode 100644 index 0000000..8b5f17c --- /dev/null +++ b/scripts/restore/restore_system_adopt.sh @@ -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 [--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 ' 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 +} diff --git a/scripts/source/files/arrays/files_restore.sh b/scripts/source/files/arrays/files_restore.sh index 8993e25..29d0565 100755 --- a/scripts/source/files/arrays/files_restore.sh +++ b/scripts/source/files/arrays/files_restore.sh @@ -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" ) diff --git a/scripts/source/files/arrays/function_manifest.sh b/scripts/source/files/arrays/function_manifest.sh index c0fd568..a815684 100644 --- a/scripts/source/files/arrays/function_manifest.sh +++ b/scripts/source/files/arrays/function_manifest.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 "$@"; } diff --git a/scripts/storage/storage_locations.sh b/scripts/storage/storage_locations.sh index f1834af..0ac737f 100644 --- a/scripts/storage/storage_locations.sh +++ b/scripts/storage/storage_locations.sh @@ -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), diff --git a/scripts/system/libreportal-ownership b/scripts/system/libreportal-ownership index 2938bca..fa44488 100644 --- a/scripts/system/libreportal-ownership +++ b/scripts/system/libreportal-ownership @@ -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__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// 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-data-remove |app-file |app-move |app-adopt |restore-stage |restore-unstage |secret-dir}" >&2; exit 2;; esac