From 5dd763713d3ff49a2a8418f85d171fc65d1fe0e5 Mon Sep 17 00:00:00 2001 From: librelad Date: Sat, 29 Aug 2026 04:33:07 +0100 Subject: [PATCH] Read a backup repository from the WebUI, and stop adoption breaking things MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit restore inspect answers "what would a restore from here bring?" without writing anything: hosts, apps with sizes, and the domains — read out of the system-config snapshot with engineDumpFile, the same way the preflight reads an app manifest. Knowing a backup hands you six domains of which four point elsewhere, before committing, is the difference between a rebuild and a surprise. restore connect is the WebUI entry point: creates the location from a base64 payload, redeems the repository password from the single-use secret channel, inspects. Deliberately does not engineInitLocation — every other path that creates a location initialises it because it is about to write there; this one reads a repository that already exists. This is what unblocks the constraint app_portable.sh records: a .lpapp could live in the WebUI because nothing secret crosses from browser to host, and the repository restore could not. The secret: channel is that missing piece. A wrong password is the ordinary case and the user retries, so a failed connect removes the location it just made. Otherwise every attempt left another half-configured destination behind. Three things found by using it: - locationRemove never worked. It unlinked as the container user, but configs/ is manager-owned, so it was always denied — and the result was never checked, so isSuccessful printed anyway and a "removed" location came back on the next listing. Now runInstallOp, and the directory is checked. - webuiSecretSweep had no callers. An abandoned flow left its repository password on disk forever. The sweep now runs in /api/setup/secret before each write, tied to the one event guaranteed to happen. - Adoption took the WebUI down. config-adopt chowned every adopted file to manager:manager 0640, and webui_logins is bind-mounted into the container, which then could not read its own credentials: exit 137 with no log line. It also clamped every parent directory it passed through, closing configs/webui and configs/backup to the container user. The fix is a principle, not a special case: a restore replaces the CONTENT of a config file and nothing else. The live install already knows who may read each one. Adoption preserves the destination's ownership and mode, defaults closed only for a file that did not exist, and never re-permissions a directory it passes through. Co-Authored-By: Claude Opus 5 --- .../backend/routes/setup-routes.js | 15 ++ docs/roadmap/first-run-restore.md | 55 ++++ scripts/backup/locations/location_remove.sh | 13 +- .../commands/restore/cli_restore_commands.sh | 12 + scripts/dev/lp-restore-adopt-test | 47 ++++ scripts/restore/restore_inspect.sh | 255 ++++++++++++++++++ scripts/source/files/arrays/files_restore.sh | 1 + .../source/files/arrays/function_manifest.sh | 18 ++ scripts/system/libreportal-ownership | 27 +- 9 files changed, 438 insertions(+), 5 deletions(-) create mode 100644 scripts/restore/restore_inspect.sh diff --git a/containers/libreportal/backend/routes/setup-routes.js b/containers/libreportal/backend/routes/setup-routes.js index 2ca011c..03c0e5f 100644 --- a/containers/libreportal/backend/routes/setup-routes.js +++ b/containers/libreportal/backend/routes/setup-routes.js @@ -189,6 +189,21 @@ router.post('/secret', requireAuth, async (req, res) => { return res.status(503).json({ error: 'Secret channel is not set up on this host' }); } + // Sweep before writing. A reference is redeemed once by the applier, but a + // flow the user abandons — closed the tab, hit a validation error, never + // pressed Save — leaves its secret behind, and these are repository + // passwords. webuiSecretSweep exists for this and had no callers at all, + // so nothing ever ran it; doing it here ties the cleanup to the one event + // that is guaranteed to happen whenever secrets are being created. + try { + const cutoff = Date.now() - 15 * 60 * 1000; + for (const name of await fsp.readdir(SECRET_DIR)) { + const f = path.join(SECRET_DIR, name); + const st = await fsp.stat(f).catch(() => null); + if (st && st.isFile() && st.mtimeMs < cutoff) await fsp.unlink(f).catch(() => {}); + } + } catch { /* a sweep that fails must never block storing the new value */ } + const id = require('crypto').randomBytes(16).toString('hex'); const file = path.join(SECRET_DIR, id); // 0640 explicitly rather than relying on the process umask: owner writes, diff --git a/docs/roadmap/first-run-restore.md b/docs/roadmap/first-run-restore.md index d85fb56..d2e368a 100644 --- a/docs/roadmap/first-run-restore.md +++ b/docs/roadmap/first-run-restore.md @@ -410,6 +410,61 @@ 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. +### 3.10 — Reading a repository without restoring it + +`restore inspect [host]` answers "what would a restore from here bring?" +without writing anything: which machines' backups are in the repository, which +apps and how big, and — the part that is least obvious to get at — **which +domains**. Those live in the system-config snapshot, so `engineDumpFile` pulls +`network/network_domains` straight out of it, the same way the preflight pulls +an app's manifest. Knowing "this backup hands you six domains, four of which +point somewhere else" before committing is the difference between a rebuild and +a surprise. + +`restore connect ` is the WebUI's entry point: it creates the +location from a payload, redeems the repository password from the single-use +secret channel, and inspects. It deliberately does **not** call +`engineInitLocation` — every other path that creates a location initialises it +because it is about to write there; this one is pointed at a repository that +already exists and is only going to be read. + +This is what unblocks §4. `app_portable.sh` records that a `.lpapp` can live in +the WebUI *because* it is unencrypted and no password has to cross from the +browser to the host — and that the repository restore therefore could not. The +`secret:` channel is that missing piece, so the constraint no longer holds. + +A wrong password is the ordinary case here and the user simply tries again, so +a failed connect removes the location it just created. Without that, every +retry left another half-configured destination behind and the Backup page grew +a column of identical dead entries. + +### 3.11 — Three more found by using it + +**`locationRemove` never worked.** It removed with `runFileOp` — the container +user — but `configs/` is manager-owned, so the unlink was always denied. The +result was never checked, and `isSuccessful` printed regardless, so a location +"removed" from the WebUI came straight back on the next listing. Now +`runInstallOp`, and the directory is checked before claiming anything. + +**`webuiSecretSweep` had no callers.** Written for exactly this and never +wired in, so a flow the user abandoned — closed the tab, hit a validation +error, never pressed Save — left its repository password on disk indefinitely. +The sweep now runs in the `/api/setup/secret` route before each write, which +ties it to the one event guaranteed to happen whenever secrets are being made. + +**Adoption took the WebUI down.** `config-adopt` chowned every adopted file to +`manager:manager 0640`. `webui_logins` is bind-mounted into the WebUI +container, which then could not read its own credentials file: the container +died with exit 137 and *no log line at all*, which is a genuinely hard failure +to read. It also clamped every parent directory it passed through, closing +`configs/webui` and `configs/backup` to the container user. + +The fix is a principle rather than a special case: **a restore replaces the +content of a config file and nothing else.** The live install already knows who +is allowed to read each one. Adoption now preserves the destination's existing +ownership and mode, defaults closed only for a file that did not exist, and +never re-permissions a directory it merely passes through. + ## 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. diff --git a/scripts/backup/locations/location_remove.sh b/scripts/backup/locations/location_remove.sh index 4c6aaf1..275e337 100644 --- a/scripts/backup/locations/location_remove.sh +++ b/scripts/backup/locations/location_remove.sh @@ -20,7 +20,18 @@ locationRemove() local name_var="CFG_BACKUP_LOC_${idx}_NAME" local label="${!name_var:-Location $idx}" - runFileOp rm -rf "$dir" + # runInstallOp, not runFileOp: configs/ is manager-owned and the container + # user this used to run as cannot unlink inside it. The removal failed + # every time while the success message below printed anyway, so a location + # "removed" from the WebUI came straight back on the next listing. + runInstallOp rm -rf "$dir" + + # And check. An unconditional isSuccessful after an unchecked command is + # what hid this: the only evidence a person ever saw was the message. + if [[ -d "$dir" ]]; then + isError "Location $idx '$label' could not be removed — $dir is still there." + return 1 + fi # Best-effort unset of the now-orphaned env vars so other code in this # process doesn't see stale values. diff --git a/scripts/cli/commands/restore/cli_restore_commands.sh b/scripts/cli/commands/restore/cli_restore_commands.sh index bacf4d3..9a0fd45 100755 --- a/scripts/cli/commands/restore/cli_restore_commands.sh +++ b/scripts/cli/commands/restore/cli_restore_commands.sh @@ -41,6 +41,18 @@ cliHandleRestoreCommands() # staging dir; never overwrites live config. Optional location idx. backupRestoreSystemConfig "$action" ;; + connect) + # Connect a repository from a base64 JSON payload and report what + # it holds. The WebUI's restore branch; see restore_inspect.sh. + # restore connect + restoreConnectInspect "$action" + ;; + inspect) + # Report what a restore from this repository would bring, without + # writing anything. + # restore inspect [host] + restoreInspect "$action" "$name" + ;; 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 diff --git a/scripts/dev/lp-restore-adopt-test b/scripts/dev/lp-restore-adopt-test index f5cadba..5cdcbd1 100755 --- a/scripts/dev/lp-restore-adopt-test +++ b/scripts/dev/lp-restore-adopt-test @@ -125,6 +125,53 @@ else echo " SKIP $H not installed" fi +echo "adoption preserves what the destination already was" +# The whole reason this is checked rather than assumed: config-adopt used to +# chown every adopted file to manager:manager 0640. webui_logins is +# bind-mounted into the WebUI container, which then could not read its own +# credentials file — the container died with exit 137 and no log line at all. +# A restore replaces the CONTENT of a config file; the live install already +# knows who is allowed to read it. +H=/usr/local/lib/libreportal/libreportal-ownership +if [[ -x "$H" ]]; then + stage=$(mktemp -d); mkdir -p "$stage/webui"; echo "adopted=yes" > "$stage/webui/webui_logins" + target="$CONFIGS/webui/webui_logins" + if [[ -f "$target" ]]; then + want="$(stat -c '%a %U:%G' "$target")" + keep=$(mktemp); cp -a "$target" "$keep" + "$H" config-adopt "$stage" "webui/webui_logins" >/dev/null 2>&1 + got="$(stat -c '%a %U:%G' "$target")" + chk "ownership and mode unchanged" "$got" "$want" + grep -q '^adopted=yes$' "$target" && echo " ok the content was replaced" \ + || { echo " FAIL the content was not replaced"; fail=1; } + cat "$keep" > "$target"; chmod "${want%% *}" "$target"; chown "${want##* }" "$target"; rm -f "$keep" + else + echo " SKIP no webui_logins to test against" + fi + # A file that does not exist yet gets the closed default. + mkdir -p "$stage/general"; echo "n=1" > "$stage/general/lp_test_new_file" + "$H" config-adopt "$stage" "general/lp_test_new_file" >/dev/null 2>&1 + if [[ -f "$CONFIGS/general/lp_test_new_file" ]]; then + chk "a new file defaults closed" "$(stat -c '%a' "$CONFIGS/general/lp_test_new_file")" "640" + rm -f "$CONFIGS/general/lp_test_new_file" + else + echo " FAIL a new file was not created"; fail=1 + fi + rm -rf "$stage" +fi + +echo "adoption does not re-permission directories it passes through" +# config-adopt clamped every parent to manager:manager 0750, including ones +# that already existed. That closed configs/backup to the container user and +# broke the credential read, and closed configs/webui, which is what actually +# took the WebUI down. +for d in general network security webui backup; do + [[ -d "$CONFIGS/$d" ]] || continue + m=$(stat -c '%a' "$CONFIGS/$d") + if [[ "$m" == "755" ]]; then echo " ok configs/$d is traversable ($m)" + else echo " FAIL configs/$d is $m — the container user cannot traverse it"; fail=1; fi +done + 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 diff --git a/scripts/restore/restore_inspect.sh b/scripts/restore/restore_inspect.sh new file mode 100644 index 0000000..69cd3cd --- /dev/null +++ b/scripts/restore/restore_inspect.sh @@ -0,0 +1,255 @@ +#!/bin/bash + +# Read a backup repository and report what a restore from it would bring — +# without writing anything. +# +# This is what lets a person decide before committing, in the WebUI as well as +# the installer: which machine's backups are in there, which apps, how big, how +# recent, and which domains would come across. Every answer is read out of +# snapshots; nothing is restored, nothing on this machine is touched. +# +# The domains matter most and are the least obvious to get at. They live in the +# system-config snapshot, in network/network_domains — so they can be pulled +# with engineDumpFile the same way the preflight pulls an app's manifest, long +# before anything is adopted. Knowing "this backup will hand you six domains, +# four of which point somewhere else" is the difference between a rebuild and a +# surprise. +# +# Emits one JSON object, for the WebUI to render and for a person to read. + +# The domains recorded in the repository's system-config snapshot. +# Silent on failure: an older backup may not carry the file, and that is not a +# reason to refuse an inspection. +restoreInspectDomains() +{ + local idx="$1" host="$2" + + # There is no engineSystemSnapshotLatestId — only the JSON listing — so + # take the last entry, which is the newest: restic returns snapshots + # oldest-first. + local snap + snap=$(engineSystemSnapshotsJson "$idx" "$host" 2>/dev/null \ + | grep -o '"short_id":"[^"]*"' | tail -1 | cut -d'"' -f4) + [[ -n "$snap" ]] || return 0 + + # The snapshot is the config tree, taken from this install's configs dir, so + # the file sits at that absolute path inside it. Ask for a few plausible + # roots rather than assuming one: the source machine's config root is + # exactly the thing that differs between installs. + local base out="" + for base in "${configs_dir%/}" "/libreportal-system/configs" "/docker/configs"; do + out=$(engineDumpFile "$idx" "$snap" "$base/network/network_domains" 2>/dev/null) + [[ -n "$out" ]] && break + done + [[ -n "$out" ]] || return 0 + + local line v + 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\n' "$v" + done <<< "$out" +} + +# Inspect a repository. Prints JSON on stdout. +# +# restore inspect [host] +# +# With no host it reports every host it found and picks the one with the most +# apps as the suggestion — a repository that has been pointed at two machines +# is a normal thing to end up with, and guessing silently is not. +restoreInspect() +{ + local idx="${1:-}" want_host="${2:-}" + if [[ -z "$idx" ]]; then + echo '{"error":"a backup location is required"}' + return 1 + fi + + local snaps + snaps=$(restoreFirstRunDiscover "$idx" 2>/dev/null) + if [[ -z "$snaps" || "$snaps" == "null" ]]; then + # The single most common cause by a distance, and worth saying plainly + # rather than as "discovery failed". + echo '{"error":"Could not read that repository — wrong password, or not a LibrePortal backup."}' + return 1 + fi + + local -a hosts=() + local h + while IFS= read -r h; do [[ -n "$h" ]] && hosts+=("$h"); done \ + < <(printf '%s' "$snaps" | grep -o '"hostname":"[^"]*"' | cut -d'"' -f4 | sort -u) + + if (( ${#hosts[@]} == 0 )); then + echo '{"error":"No LibrePortal backups found in that repository."}' + return 1 + fi + + local host="$want_host" + if [[ -z "$host" ]]; then + # The one with the most apps, not simply the first: a repository often + # carries a stray snapshot from a machine that was only ever tested. + local best="" best_n=-1 n + for h in "${hosts[@]}"; do + n=$(migrateDiscoverApps "$h" "$idx" 2>/dev/null | grep -c .) + if (( n > best_n )); then best_n=$n; best="$h"; fi + done + host="$best" + fi + + local -a apps=() + local a + while IFS= read -r a; do [[ -n "$a" ]] && apps+=("$a"); done \ + < <(migrateDiscoverApps "$host" "$idx" 2>/dev/null) + + # --- assemble --- + local out='{' + out+='"host":"'$(_lpJsonStr "$host")'",' + + out+='"hosts":[' + local first=1 + for h in "${hosts[@]}"; do + [[ $first -eq 0 ]] && out+=',' + out+='"'$(_lpJsonStr "$h")'"'; first=0 + done + out+='],' + + out+='"apps":[' + first=1 + local size_b size_h + for a in "${apps[@]}"; do + [[ $first -eq 0 ]] && out+=',' + # Size and date come from the app's own manifest where there is one. + # An older backup without a manifest still lists — it just has less to + # say about itself, which is better than being left out of the list. + size_b=$(restorePreflightManifest "$idx" "$a" "$host" 2>/dev/null \ + | tr -d ' \n\t' | grep -o '"size_bytes":[0-9]*' | cut -d: -f2) + size_h="" + [[ -n "$size_b" ]] && size_h=$(_restorePfSize "$size_b") + out+='{"name":"'$(_lpJsonStr "$a")'","size":"'$(_lpJsonStr "$size_h")'"}' + first=0 + done + out+='],' + + out+='"domains":[' + first=1 + local d + while IFS= read -r d; do + [[ -z "$d" ]] && continue + [[ $first -eq 0 ]] && out+=',' + out+='"'$(_lpJsonStr "$d")'"'; first=0 + done < <(restoreInspectDomains "$idx" "$host") + out+=']}' + + printf '%s\n' "$out" + return 0 +} + +# Connect a repository described by a base64 JSON payload, then inspect it. +# +# restoreConnectInspect +# +# Payload: {"location":{"name","type","path","uri","ssh_*","password_ref",…}, +# "host":"optional"} +# +# The password arrives as a REFERENCE, never a value. This payload reaches a +# task command line and tasks are recorded world-readable, so a secret +# travelling as itself would be readable by any local account — +# webuiSecretResolve redeems it here, once, at the moment of the write. Same +# contract as the wizard's backup destinations. +# +# Deliberately does NOT call engineInitLocation. Every other path that creates +# a location initialises it, because it is about to write backups there. This +# one is pointed at a repository that already exists and is only going to be +# read; initialising is at best a no-op and at worst the wrong answer to +# "that path is empty". +restoreConnectInspect() +{ + local b64="${1:-}" + if [[ -z "$b64" ]]; then + echo '{"error":"no payload"}' + return 1 + fi + + local payload + payload=$(printf '%s' "$b64" | base64 -d 2>/dev/null) + if [[ -z "$payload" ]]; then + echo '{"error":"payload could not be decoded"}' + return 1 + fi + + local loc; loc=$(jq -c '.location // {}' <<< "$payload" 2>/dev/null) + [[ -n "$loc" && "$loc" != "{}" ]] || { echo '{"error":"no backup location in the payload"}'; return 1; } + + local name type idx + name=$(jq -r '.name // "restore-source"' <<< "$loc") + type=$(jq -r '.type // "local"' <<< "$loc") + + idx=$(locationAdd "$name" "$type" 2>/dev/null | tail -1) + if [[ ! "$idx" =~ ^[0-9]+$ ]]; then + echo '{"error":"Could not create a backup location to read from."}' + return 1 + fi + + local cfg; cfg=$(backupLocationConfig "$idx") + if [[ ! -f "$cfg" ]]; then + echo '{"error":"The backup location was created but has no config file."}' + return 1 + fi + + local v + v=$(jq -r '.path // ""' <<< "$loc") + if [[ -n "$v" ]]; then + updateConfigOption "CFG_BACKUP_LOC_${idx}_PATH_MODE" "custom" "$cfg" >/dev/null + updateConfigOption "CFG_BACKUP_LOC_${idx}_PATH" "$v" "$cfg" >/dev/null + fi + v=$(jq -r '.uri // ""' <<< "$loc") + [[ -n "$v" ]] && updateConfigOption "CFG_BACKUP_LOC_${idx}_URI" "$v" "$cfg" >/dev/null + + local k + for k in ssh_user ssh_host ssh_port ssh_path s3_bucket s3_key_id b2_account_id; do + v=$(jq -r --arg k "$k" '.[$k] // ""' <<< "$loc") + [[ -n "$v" ]] && updateConfigOption "CFG_BACKUP_LOC_${idx}_${k^^}" "$v" "$cfg" >/dev/null + done + + # Every secret in the payload, redeemed at the point of use. + local ref pw + for k in password ssh_pass s3_key b2_key connect_token; do + ref=$(jq -r --arg k "${k}_ref" '.[$k] // ""' <<< "$loc") + [[ -n "$ref" ]] || continue + if pw=$(webuiSecretResolve "$ref" 2>/dev/null) && [[ -n "$pw" ]]; then + updateConfigOption "CFG_BACKUP_LOC_${idx}_${k^^}" "$pw" "$cfg" >/dev/null + pw="" + fi + done + + updateConfigOption "CFG_BACKUP_LOC_${idx}_ENABLED" "true" "$cfg" >/dev/null + # The credentials just landed, so close the directory to everything that is + # not LibrePortal before anything else runs. + runOwnership config-secure >/dev/null 2>&1 || true + source "$cfg" 2>/dev/null + + local host; host=$(jq -r '.host // ""' <<< "$payload") + local out; out=$(restoreInspect "$idx" "$host") + + # The caller needs the index to restore from later, and it is not otherwise + # discoverable from the WebUI without guessing. + if grep -q '"error"' <<< "$out"; then + # Remove the location we just made. A wrong password is the ordinary + # case here and the user will simply try again — without this, every + # retry leaves another half-configured destination behind, and after a + # few attempts the Backup page lists a column of identical dead + # entries the user then has to clean up by hand. + locationRemove "$idx" >/dev/null 2>&1 || true + printf '%s\n' "$out" + return 1 + fi + # Spliced with jq rather than by trimming a closing brace: string surgery + # on JSON is how you ship something that parses on your machine and not on + # the next one. + jq -c --arg idx "$idx" '. + {location_idx: $idx}' <<< "$out" + return 0 +} diff --git a/scripts/source/files/arrays/files_restore.sh b/scripts/source/files/arrays/files_restore.sh index 29d0565..8fc9a59 100755 --- a/scripts/source/files/arrays/files_restore.sh +++ b/scripts/source/files/arrays/files_restore.sh @@ -11,5 +11,6 @@ restore_scripts=( "restore/restore_preflight.sh" "restore/restore_system_adopt.sh" "restore/restore_domains.sh" + "restore/restore_inspect.sh" ) diff --git a/scripts/source/files/arrays/function_manifest.sh b/scripts/source/files/arrays/function_manifest.sh index a815684..cbadfef 100644 --- a/scripts/source/files/arrays/function_manifest.sh +++ b/scripts/source/files/arrays/function_manifest.sh @@ -905,10 +905,12 @@ declare -gA LP_FN_MAP=( [resticSnapshotPaths]="backup/engine/restic_snapshots.sh" [resticSnapshotsJson]="backup/engine/restic_snapshots.sh" [resticSystemSnapshotsJson]="backup/engine/restic_snapshots.sh" + [_restoreAdoptAllowDirs]="restore/restore_system_adopt.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" + [restoreConnectInspect]="restore/restore_inspect.sh" [restoreDbRehydratePreStart]="backup/db/backup_db.sh" [restoreDbReplayPostStart]="backup/db/backup_db.sh" [restoreDomainCheck]="restore/restore_domains.sh" @@ -918,11 +920,15 @@ declare -gA LP_FN_MAP=( [restoreFilesRehydratePreStart]="backup/files/backup_files.sh" [restoreFirstRunBulk]="restore/restore_first_run.sh" [restoreFirstRunDiscover]="restore/restore_first_run.sh" + [restoreInspect]="restore/restore_inspect.sh" + [restoreInspectDomains]="restore/restore_inspect.sh" + [_restoreIsPrivateIp]="restore/restore_domains.sh" [_restorePfSize]="restore/restore_preflight.sh" [restorePickSnapshot]="restore/restore_app_pick.sh" [restorePreflightApp]="restore/restore_preflight.sh" [restorePreflightManifest]="restore/restore_preflight.sh" [restorePreflightReport]="restore/restore_preflight.sh" + [restoreServerPublicIp]="restore/restore_domains.sh" [restoreSystemAdopt]="restore/restore_system_adopt.sh" [_rocketchatApi]="rocketchat/scripts/rocketchat_auth.sh" [_rocketchatBaseUrl]="rocketchat/scripts/rocketchat_auth.sh" @@ -2163,10 +2169,12 @@ declare -gA LP_FN_ROOT=( [resticSnapshotPaths]="scripts" [resticSnapshotsJson]="scripts" [resticSystemSnapshotsJson]="scripts" + [_restoreAdoptAllowDirs]="scripts" [_restoreAdoptAllowList]="scripts" [restoreAdoptIsFirstRun]="scripts" [restoreAppRunHook]="scripts" [restoreAppStart]="scripts" + [restoreConnectInspect]="scripts" [restoreDbRehydratePreStart]="scripts" [restoreDbReplayPostStart]="scripts" [restoreDomainCheck]="scripts" @@ -2176,11 +2184,15 @@ declare -gA LP_FN_ROOT=( [restoreFilesRehydratePreStart]="scripts" [restoreFirstRunBulk]="scripts" [restoreFirstRunDiscover]="scripts" + [restoreInspect]="scripts" + [restoreInspectDomains]="scripts" + [_restoreIsPrivateIp]="scripts" [_restorePfSize]="scripts" [restorePickSnapshot]="scripts" [restorePreflightApp]="scripts" [restorePreflightManifest]="scripts" [restorePreflightReport]="scripts" + [restoreServerPublicIp]="scripts" [restoreSystemAdopt]="scripts" [_rocketchatApi]="containers" [_rocketchatBaseUrl]="containers" @@ -3459,10 +3471,12 @@ 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 "$@"; } +_restoreAdoptAllowDirs() { unset -f _restoreAdoptAllowDirs; __lpAutoload "${install_scripts_dir}restore/restore_system_adopt.sh"; _restoreAdoptAllowDirs "$@"; } _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 "$@"; } +restoreConnectInspect() { unset -f restoreConnectInspect; __lpAutoload "${install_scripts_dir}restore/restore_inspect.sh"; restoreConnectInspect "$@"; } 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 "$@"; } @@ -3472,11 +3486,15 @@ restoreDomainsDropElsewhere() { unset -f restoreDomainsDropElsewhere; __lpAutolo 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 "$@"; } +restoreInspect() { unset -f restoreInspect; __lpAutoload "${install_scripts_dir}restore/restore_inspect.sh"; restoreInspect "$@"; } +restoreInspectDomains() { unset -f restoreInspectDomains; __lpAutoload "${install_scripts_dir}restore/restore_inspect.sh"; restoreInspectDomains "$@"; } +_restoreIsPrivateIp() { unset -f _restoreIsPrivateIp; __lpAutoload "${install_scripts_dir}restore/restore_domains.sh"; _restoreIsPrivateIp "$@"; } _restorePfSize() { unset -f _restorePfSize; __lpAutoload "${install_scripts_dir}restore/restore_preflight.sh"; _restorePfSize "$@"; } restorePickSnapshot() { unset -f restorePickSnapshot; __lpAutoload "${install_scripts_dir}restore/restore_app_pick.sh"; restorePickSnapshot "$@"; } 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 "$@"; } +restoreServerPublicIp() { unset -f restoreServerPublicIp; __lpAutoload "${install_scripts_dir}restore/restore_domains.sh"; restoreServerPublicIp "$@"; } 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 "$@"; } diff --git a/scripts/system/libreportal-ownership b/scripts/system/libreportal-ownership index fa44488..462fd6e 100644 --- a/scripts/system/libreportal-ownership +++ b/scripts/system/libreportal-ownership @@ -561,12 +561,31 @@ config_adopt() { chmod 0750 -- "${real_dst%/*}" 2>/dev/null fi + # Preserve whatever the destination already is. A restore replaces the + # CONTENT of a config file; it has no business re-deciding who may read it. + # The live install already knows — webui_logins and webui_logs are + # bind-mounted into the container and must stay readable by the container + # user, and imposing manager:manager 0640 on them took the WebUI down + # immediately (exit 137, no log line, because the process could not read + # its own credentials file). + local had_owner="" had_mode="" + if [[ -e "$real_dst" ]]; then + had_owner="$(stat -c '%U:%G' -- "$real_dst" 2>/dev/null)" + had_mode="$(stat -c '%a' -- "$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 + + if [[ -n "$had_owner" && -n "$had_mode" ]]; then + chown "$had_owner" -- "$real_dst" || return 1 + chmod "$had_mode" -- "$real_dst" || return 1 + else + # New file: these can carry repository passwords and login hashes, so + # the default is closed. + chown "$MANAGER:$MANAGER" -- "$real_dst" || return 1 + chmod 0640 -- "$real_dst" || return 1 + fi return 0 }