feat(install): New setup or Restore from backup — two installer paths

The first question the installer asks is now which of the two this is,
because they are different installs rather than a setting: a restore
skips the setup wizard entirely, since identity, domains and app choices
all come out of the backup instead of being asked for twice.

Putting this in the INSTALLER rather than the WebUI wizard is what makes
it tractable. The repository password is typed at a root terminal and
handed to restic in the same process. Asking in the browser would mean
moving that password across the container/manager boundary, where the
only available channels are a world-readable task file or a command line
visible in ps — the blocker recorded in first-run-restore.md §4.1. Here
that problem simply does not arise.

The password is read with -s, never echoed, and written straight into the
0640 manager-owned location config rather than passed as an argument, so
it does not appear in ps or any log.

Order follows what the CLI already enforces: connect, discover, restore
the SYSTEM CONFIG first — it carries every other location's credentials,
so the one password the user remembers unlocks the rest — then the apps
via restoreFirstRunBulk. Finally the setup-wizard lock is set, because
the backup has already answered everything the wizard would ask.

Verified under a pty: mode default, a repository path that does not
exist, and an empty password are each refused with the reason. The host
and app parsing was checked against the live repository on this box —
one host, 13 apps extracted correctly from real restic output.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
librelad 2026-08-27 08:47:33 +01:00
parent d44ebf0ca7
commit e165e28ceb

206
init.sh
View File

@ -305,6 +305,206 @@ initPickRoots()
return 0 return 0
} }
# First question of the whole installer: is this a new server, or a replacement
# for one?
#
# The two answers are genuinely different installs, not a setting — a restore
# skips the setup wizard entirely, because identity, domains and app choices all
# come out of the backup rather than being asked for twice.
#
# It lives HERE, in the installer, rather than in the WebUI wizard, and that is
# what makes it tractable: the repository password is typed at a root terminal
# and used by restic in the same process. Asking in the browser would mean
# moving that password across the container/manager boundary, where the only
# available channels are a world-readable task file or a command line visible in
# ps (see docs/roadmap/first-run-restore.md §4.1).
init_mode="new"
initPickMode()
{
[[ "$init_unattended_mode" == true ]] && return 0
[[ -t 0 && -t 1 ]] || return 0
echo ""
isHeader "What are we doing?"
echo ""
printf ' 1) %s\n' "New setup — install LibrePortal fresh on this machine"
printf ' 2) %s\n' "Restore from backup — rebuild a server from an existing backup"
echo ""
local choice=""
isQuestion "Choose [1]:"
read -r choice
echo ""
case "$choice" in
2) init_mode="restore"
isSuccessful "Restore mode — LibrePortal will be installed, then filled from your backup."
echo "" ;;
*) init_mode="new" ;;
esac
return 0
}
# The restore half, run after the platform is installed and the WebUI is up.
#
# Order matters and mirrors what the CLI already enforces: the system config
# comes back FIRST, because it carries every other backup location's
# credentials — so one password the user remembers unlocks the rest — and only
# then are apps restored.
initRestoreFromBackup()
{
[[ "$init_mode" == "restore" ]] || return 0
local as_manager=(sudo -u "$sudo_user_name")
echo ""
isHeader "Restore from backup"
echo ""
echo " Your backups live in a repository — a folder on a disk, or a remote"
echo " server. Not a single file, so point us at the repository itself."
echo ""
local btype="" bpath="" bpass=""
printf ' 1) %s\n' "A folder on this machine or a plugged-in disk"
printf ' 2) %s\n' "An SFTP server"
echo ""
isQuestion "Where is it? [1]:"
read -r btype
echo ""
local loc_type="local"
local ssh_user="" ssh_host="" ssh_path=""
if [[ "$btype" == "2" ]]; then
loc_type="sftp"
isQuestion "SSH user:"; read -r ssh_user; echo ""
isQuestion "SSH host:"; read -r ssh_host; echo ""
isQuestion "Remote path to the repository:"; read -r ssh_path; echo ""
[[ -n "$ssh_user" && -n "$ssh_host" && -n "$ssh_path" ]] || {
isError "SFTP needs a user, a host and a path — skipping the restore."
return 1
}
else
isQuestion "Path to the repository:"; read -r bpath; echo ""
if [[ -z "$bpath" || ! -d "$bpath" ]]; then
isError "'$bpath' is not a directory — skipping the restore."
isNotice "You can do this later with: libreportal backup location add …"
return 1
fi
fi
# -s: the repository password is the key to every backup the user has, so it
# is never echoed and never lands in a command line or a log.
isQuestion "Repository password:"
read -rs bpass
echo ""
echo ""
if [[ -z "$bpass" ]]; then
isError "No password given — an encrypted repository cannot be opened without it."
return 1
fi
# --- connect ---------------------------------------------------------------
local idx
idx=$("${as_manager[@]}" libreportal backup location add restore-source "$loc_type" 2>/dev/null | tail -1 | tr -dc '0-9')
if [[ -z "$idx" ]]; then
isError "Could not create a backup location — skipping the restore."
return 1
fi
local cfg="$configs_dir/backup/locations/$idx/location.config"
if [[ ! -f "$cfg" ]]; then
isError "Backup location $idx has no config at $cfg — skipping the restore."
return 1
fi
# Written straight into the (0640, manager-owned) location config rather than
# passed as an argument, so the password never appears in ps or a task file.
_initSetCfg() { sed -i "s|^$1=.*|$1=$2|" "$cfg"; }
if [[ "$loc_type" == "sftp" ]]; then
_initSetCfg "CFG_BACKUP_LOC_${idx}_SSH_USER" "$ssh_user"
_initSetCfg "CFG_BACKUP_LOC_${idx}_SSH_HOST" "$ssh_host"
_initSetCfg "CFG_BACKUP_LOC_${idx}_SSH_PATH" "$ssh_path"
else
_initSetCfg "CFG_BACKUP_LOC_${idx}_PATH_MODE" "custom"
_initSetCfg "CFG_BACKUP_LOC_${idx}_PATH" "$bpath"
fi
_initSetCfg "CFG_BACKUP_LOC_${idx}_PASSWORD" "$bpass"
_initSetCfg "CFG_BACKUP_LOC_${idx}_ENABLED" "true"
unset -f _initSetCfg
bpass=""
chown "$sudo_user_name":"$sudo_user_name" "$cfg" 2>/dev/null
chmod 0640 "$cfg" 2>/dev/null
# --- discover ---------------------------------------------------------------
isNotice "Reading the repository…"
local snaps
snaps=$("${as_manager[@]}" libreportal restore first-run discover "$idx" 2>/dev/null)
if [[ -z "$snaps" || "$snaps" == "null" ]]; then
isError "Could not read any snapshots. Wrong password, or not a LibrePortal repository."
isNotice "The location was kept as '$idx' — fix it on the Backup page and restore from there."
return 1
fi
local hosts
hosts=$(printf '%s' "$snaps" | grep -o '"hostname":"[^"]*"' | cut -d'"' -f4 | sort -u)
[[ -z "$hosts" ]] && { isError "No hosts found in that repository."; return 1; }
local host_count; host_count=$(printf '%s\n' "$hosts" | grep -c .)
local source_host
if (( host_count == 1 )); then
source_host="$hosts"
isSuccessful "Found backups from '$source_host'"
else
echo ""
echo " Backups were found from more than one machine:"
local i=1 h
while IFS= read -r h; do printf ' %s) %s\n' "$i" "$h"; i=$((i+1)); done <<< "$hosts"
echo ""
isQuestion "Which one? [1]:"
local hc; read -r hc; echo ""
[[ "$hc" =~ ^[0-9]+$ ]] || hc=1
source_host=$(printf '%s\n' "$hosts" | sed -n "${hc}p")
[[ -z "$source_host" ]] && source_host=$(printf '%s\n' "$hosts" | head -1)
isSuccessful "Restoring from '$source_host'"
fi
# --- system config first ----------------------------------------------------
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."
# --- apps -------------------------------------------------------------------
local apps
apps=$(printf '%s' "$snaps" \
| grep -o '"app=[^"]*"' | sed 's/"app=\(.*\)"/\1/' | sort -u)
if [[ -z "$apps" ]]; then
isNotice "No app snapshots found for '$source_host' — nothing else to restore."
return 0
fi
echo ""
echo " Apps in this backup:"
local a
while IFS= read -r a; do printf ' %s\n' "$a"; done <<< "$apps"
echo ""
isQuestion "Restore all of them? [Y/n]:"
local yn; read -r yn; echo ""
case "$yn" in
[nN]*) isNotice "Skipped. Restore them any time from the Backup page."; return 0 ;;
esac
local -a app_list=()
while IFS= read -r a; do [[ -n "$a" ]] && app_list+=("$a"); done <<< "$apps"
isNotice "Restoring ${#app_list[@]} apps — this takes a while."
"${as_manager[@]}" libreportal restore first-run bulk "$idx" "$source_host" "${app_list[@]}"
# The wizard would ask for identity, domains and apps that the backup has
# already answered, so mark it done rather than asking twice.
local lock="$containers_dir/libreportal/frontend/data/.setup_complete"
[[ -d "$(dirname "$lock")" ]] && { : > "$lock"; chown "$CFG_DOCKER_INSTALL_USER":"$CFG_DOCKER_INSTALL_USER" "$lock" 2>/dev/null; chmod 0644 "$lock" 2>/dev/null; }
isSuccessful "Restore complete"
return 0
}
# Validate the chosen roots before anything is created/baked. Called from the # Validate the chosen roots before anything is created/baked. Called from the
# install flow only (NOT at source time — the CLI sources init.sh too). Aborts on # install flow only (NOT at source time — the CLI sources init.sh too). Aborts on
# an unsafe choice; the root helpers also re-check at runtime (defence in depth). # an unsafe choice; the root helpers also re-check at runtime (defence in depth).
@ -2038,6 +2238,10 @@ if [[ $EUID -ne 0 ]]; then
exit 1 exit 1
else else
if [[ "$param1" == "init" ]]; then if [[ "$param1" == "init" ]]; then
# New setup or restore? Asked before anything else, because the two
# are different installs rather than a setting.
initPickMode
# Ask where things go before anything is validated or created. # Ask where things go before anything is validated or created.
# No-op when unattended, when the flags were passed, or when there # No-op when unattended, when the flags were passed, or when there
# is only one possible answer. # is only one possible answer.
@ -2077,6 +2281,8 @@ else
initLibrePortalCommand initLibrePortalCommand
initUpdateConfigs initUpdateConfigs
initContainerLayer initContainerLayer
# Restore mode: the platform is up, now fill it from the backup.
initRestoreFromBackup
completeInitMessage completeInitMessage
elif [[ "$param1" == "uninstall" ]]; then elif [[ "$param1" == "uninstall" ]]; then
runFullUninstall runFullUninstall